openvibers/openvibe.codes Commits
Know K5: workspace knowledge — what a run learned, recalled by the next one (#286)
Changes
@@@ -21,6 +21,7 @@ One place to send a coding task to the agent that fits it — by what the task n| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. || Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. || Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |++| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. || The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. || Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. || Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |@@@ -109,6 +110,7 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r`npm test` (development: `fnm exec --using=22.22.1 npm test`) runs every `test/*.test.js` in its own process ([test/run.js](test/run.js)), each on a temp PGlite database with in-process mocks of OpenVibe.Network, Events and Media ([test/helpers/boot.js](test/helpers/boot.js)); `CODES_TEST_STORE=pg` runs the same against the containers. The main files, one line each:- [test/harness-run.test.js](test/harness-run.test.js) — every CLI adapter turns its agent's output into the shared events with one result; availability comes from PATH and keys; routing leaves out what is not here and says why; a failed or silent attempt hands the task on; redaction; the local run store.++- [test/knowledge.test.js](test/knowledge.test.js) — a run's ending is captured (Learned: lines, a failure, the failed command, the repository's commands) with secrets redacted; the next run's prompt gets them; every row is visible, editable, pinnable and deletable with its source run; a team member reads a shared row and a non-member does not; the person's token API answers the ov-know MCP; the row is exported and erased with the account.- [test/harness-api.test.js](test/harness-api.test.js) — the API agent loop against a stand-in OpenAI-compatible server: tools confined to the working directory, permission levels, `.env`/`.git`/outside paths refused, an HTTP error ends the run, resume continues the conversation.- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.@@@ -227,6 +227,9 @@ function parseArgs(raw, env = process.env) {agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,...(effort ? { effort } : {}),++ // Know K5: the lessons and facts earlier runs learned, from the server's own store and already a short block.++ // It is prepended to the prompt in runJob, so every harness (CLI or API) gets it the same way.++ ...(typeof a.knowledge === 'string' && a.knowledge.trim() ? { knowledge: a.knowledge.trim().slice(0, 4000) } : {}),// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts....(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),};@@@ -575,6 +578,21 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {return out;}++/**++ * The prompt the agent gets: the knowledge block the server recalled (when there is one), the person's brief, and — for++ * a code task — the one line that lets the next run learn from this one. Every adapter takes the prompt as its brief,++ * so this is the same for a CLI harness and an API model.++ */++function agentPrompt(job) {++ const parts = [];++ if (job.knowledge) parts.push(job.knowledge);++ parts.push(job.prompt);++ if (job.kind === 'code') {++ parts.push('---\n## Before you finish (OpenVibe.Codes Knowledge)\n\nEnd with a `Learned:` block of at most three lines: one fact the next agent on this repository should know, each with the file, command or URL that shows it. Leave it out when nothing was new.');++ }++ return parts.join('\n\n');++}++/*** Run one job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.* → the result document.@@@ -660,7 +678,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmsay({ t: 'step', text: 'Preparing an empty benchmark workspace' });}const run = harness.run({-- prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null, cwd: work,++ prompt: agentPrompt(job), task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null, cwd: work,permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,// A job on the person's Node runs in its sandbox (own namespaces, a read-only root, no home folder, only// what the job was given); the Node sets OPENVIBE_JOB_ID there. Only inside it may an agent with no@@@ -748,4 +766,4 @@ function resultWriter(emit) {};}--module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };++module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, agentPrompt };nnew file mode 100644@@@ -0,0 +1,38 @@++-- phase: expand++-- Know K5: a person's workspace knowledge — the lessons and facts their own runs taught them.++--++-- A row belongs to one person. It is private unless the person shares it with a team (a Network developer project,++-- the same prj_… a run belongs to): visibility 'team' plus team_id makes it readable by that project's members.++-- The text is scrubbed of secrets before it is written here; source_task points at the run that taught it.++--++-- A capture is idempotent: (subject, kind, dedupe_key) is unique, so running the same capture twice raises `seen`++-- and moves `last_seen_at` instead of writing a second row. Nothing in this table is a token or a key.++++CREATE TABLE IF NOT EXISTS codes_knowledge (++ id text COLLATE "C" PRIMARY KEY, -- kn_<ULID>++ subject text COLLATE "C" NOT NULL, -- usr_… who owns the row++ team_id text COLLATE "C", -- prj_… when shared with a team++ visibility text COLLATE "C" NOT NULL DEFAULT 'private'++ CHECK (visibility IN ('private', 'team')),++ kind text COLLATE "C" NOT NULL DEFAULT 'lesson'++ CHECK (kind IN ('lesson', 'fact')),++ text text NOT NULL, -- redacted before it is stored++ repo text, -- host/owner/name, or the task's repository++ paths jsonb NOT NULL DEFAULT '[]'::jsonb, -- files the row names++ source_task text COLLATE "C", -- tsk_… the run it came from++ source_job text COLLATE "C", -- Run's job_…++ source_agent text, -- the harness that learned it++ dedupe_key text COLLATE "C", -- sha256 of the normalized text++ seen integer NOT NULL DEFAULT 1,++ pinned boolean NOT NULL DEFAULT false,++ redacted boolean NOT NULL DEFAULT false,++ created_at bigint NOT NULL,++ updated_at bigint NOT NULL,++ last_seen_at bigint NOT NULL++);++++CREATE INDEX IF NOT EXISTS codes_knowledge_subject ON codes_knowledge (subject, created_at DESC);++CREATE INDEX IF NOT EXISTS codes_knowledge_team ON codes_knowledge (team_id, created_at DESC) WHERE team_id IS NOT NULL;++CREATE INDEX IF NOT EXISTS codes_knowledge_source ON codes_knowledge (source_task) WHERE source_task IS NOT NULL;++CREATE UNIQUE INDEX IF NOT EXISTS codes_knowledge_dedupe ON codes_knowledge (subject, kind, dedupe_key)++ WHERE dedupe_key IS NOT NULL;@@@ -318,3 +318,49 @@.c4-turn-switch{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 11px;border-radius:8px;background:var(--c4-open-bg);color:var(--c4-open-ink);font-size:12px;line-height:1.45}.c4-turn-switch svg{width:15px;height:15px;flex:none;margin-top:1px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}@media (max-width:640px){.c4-fallback{grid-template-columns:28px minmax(0,1fr)}.c4-fallback>label:last-child{grid-column:2}}++++/* Know K5: the Knowledge page. A row is a card whose head carries its kind and sharing, then the text, where it came++ from, and the actions that change it. All of it is server-rendered; the details open in place. */++.kn-summary{display:flex;gap:18px;margin:0 0 14px}++.kn-summary>div{display:flex;align-items:baseline;gap:6px;font-size:12px;color:var(--c4-muted)}++.kn-summary strong{font-size:20px;color:var(--c4-ink)}++.kn-add{margin:0 0 14px;border:1px solid var(--c4-line);border-radius:10px;padding:0 12px;background:var(--c4-surface)}++.kn-add>summary{padding:10px 0}++.kn-add .c4-more-body{padding-bottom:12px}++.kn-list{display:grid;gap:12px}++.kn-row{padding:14px 16px;border-left:3px solid transparent}++.kn-row.is-pinned{border-left-color:var(--c4-accent)}++.kn-row-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px;min-width:0}++.kn-repo{overflow-wrap:anywhere}++.kn-text{margin:0 0 8px;font-size:14px;line-height:1.55;color:var(--c4-ink);overflow-wrap:anywhere;white-space:pre-wrap}++.kn-meta{margin:0 0 4px;font-size:12px;color:var(--c4-muted)}++.kn-source{margin:0 0 10px;font-size:12px;color:var(--c4-muted)}++.kn-source a{color:var(--c4-accent)}++.kn-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}++.kn-actions form{margin:0}++.kn-actions .c4-button{min-height:36px;padding:6px 12px;font-size:12px}++.kn-actions .c4-more{flex:0 0 auto;min-width:0}++.kn-actions .c4-more>summary{min-height:36px;justify-content:center;border:1px solid var(--c4-line);border-radius:8px;padding:6px 12px}++.kn-actions .c4-more-body{flex-basis:100%;width:100%}++.kn-team-note{margin:0;font-size:12px;color:var(--c4-muted)}++.c4-form-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr);gap:12px;align-items:end}++@media(max-width:640px){++ .kn-summary{gap:12px}++ .c4-form-row{grid-template-columns:1fr}++ .kn-actions .c4-button{min-height:44px}++}++/* The Knowledge page's own panels stay closed until opened, at every width (the Memory page's open-on-desktop++ rule would show every edit and share form at once), and an opened one takes the row's full width. */++.kn-add:not([open]) > .c4-more-body,.kn-actions .c4-more:not([open]) > .c4-more-body{display:none}++.kn-add.c4-more > summary,.kn-actions .c4-more > summary{display:flex}++.kn-actions .c4-more[open]{flex:1 1 100%;order:2}++.kn-actions form{order:0}++@media(max-width:640px){.c4-tabs a{padding-left:8px;padding-right:8px}}++/* Delete is quiet until asked: a text-coloured fold at the end of the row, with the real button inside it, so five++ rows do not show five filled red buttons and one tap never deletes. */++.kn-actions .kn-delete{margin-left:auto}++.kn-actions .kn-delete>summary{border-color:transparent;color:var(--c4-danger);background:none}++.kn-actions .kn-delete[open]>summary{border-color:var(--c4-line)}++.kn-actions .c4-more:not(.kn-delete)>summary{color:var(--c4-ink);font-weight:600}++.kn-confirm{margin:0 0 8px;font-size:13px;color:var(--c4-ink)}++@media(max-width:640px){.kn-actions .c4-more>summary{min-height:44px}}@@@ -45,6 +45,9 @@ const TABLES = [{ table: 'codes_outbox', subject: 'subject', file: 'events-sent.json', order: 'created_at', columns: ['event_id', 'topic', 'created_at', 'published_at'] },// The OpenVibe Git link: which forge account, and since when. The sealed tokens never leave; erased with the rest.{ table: 'codes_forge_links', subject: 'subject', file: 'openvibe-git-link.json', order: 'created_at', columns: ['forge_user', 'expires_at', 'created_at', 'updated_at'] },++ // Know K5: the lessons and facts the person's runs taught. Theirs to take and to erase; a row already shared++ // with a team leaves with its owner's account (the team's other rows stay).++ { table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },];// The rows that hang under a person's tasks: exported under the task, erased before it.@@@ -47,6 +47,7 @@ const { createOutbox, createPublisher } = require('./events/outbox');const { createEventsConsumer } = require('./events-consumer');const { createApi } = require('./http/api');const { createMeApi } = require('./http/me-api');++const { createKnowledgeApi } = require('./http/knowledge-api');const { createMovedRoutes } = require('./http/moved');const { createCodesReadiness } = require('./observability');const { createActorLimits } = require('./http/actor-limits');@@@ -54,6 +55,7 @@ const { createForgejoClient } = require('./domain/forgejo');const createAiCatalogConfig = require('./ai-catalog-config').create;const { createMachines } = require('./domain/machines');const { createMemory } = require('./domain/memory');++const { createKnowledge } = require('./domain/knowledge');const { createFleet } = require('./domain/fleet');const { createNotices } = require('./domain/notices');const { createUserSettings } = require('./domain/user-settings');@@@ -110,8 +112,10 @@ async function createApp(opts = {}) {const placementOutcomes = opts.placementOutcomes || createPlacementOutcomes({ store, now: store.now, log });const benchPriors = opts.benchPriors || createBenchPriors({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log, ttlMs: config.bench.priorsTtlMs });const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });-- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext };++ // Know K5: the lessons and facts a person's runs teach, private by default and shareable with a team.++ const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });++ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge };ctx.forgejo = createForgejoClient({ config, log });// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });@@@ -252,6 +256,8 @@ async function createApp(opts = {}) {app.use('/api/v1', createApi(ctx));app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));++ // Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).++ app.use('/api/v1', createKnowledgeApi({ config, store, knowledge: ctx.knowledge, actorLimits: ctx.actorLimits, log }));// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));nnew file mode 100644@@@ -0,0 +1,427 @@++'use strict';++++/**++ * Know K5: workspace knowledge on OpenVibe.Codes.++ *++ * The lessons and facts a person's own coding runs taught them (what failed and why, the repository's commands, its++ * traps), stored here and nowhere else. A row is private to the person unless they share it with a team — a Network++ * developer project, the same prj_… a run belongs to. A shared row is readable by the project's members and by nobody++ * else; only its owner may edit, pin, share or delete it.++ *++ * Secrets never reach the table: every text is scrubbed before it is written (server/domain/knowledge scrubs with the++ * same provider-key, GitHub-token, JWT, private-key and key=value patterns the harness uses, plus the shapes a person's++ * files carry). A capture is idempotent per (subject, kind, dedupe key), so the same ending seen twice raises `seen`++ * and moves the row's date instead of writing a second one.++ *++ * projectsOf(token) the person's projects (teams), from Network with their own token++ * list({ subject, teamIds, … }) the rows the person may read++ * remember({ … }) add one row (redacted, deduped), or raise the one it repeats++ * edit/pin/remove/share/unshare the owner's changes++ * captureTask(row) what a finished run taught: its Learned: lines, a failure, a failed command++ * recall({ subject, projectId, … }) the few rows the next run's prompt gets++ * block(rows) those rows as the prompt's own section++ *++ * A store that cannot be reached gives no rows, never an error: a run must not fail because memory is unavailable.++ */++++const crypto = require('crypto');++const { scrub: harnessScrub } = require('../../engine/redact');++++const KINDS = ['lesson', 'fact'];++const MAX_TEXT = 2000;++const MAX_BLOCK = 1600;++const MAX_CAPTURE = 6;++++// The shapes a coding tool's output carries that the harness scrubber does not name yet: Slack, AWS, Google, npm,++// Hugging Face, SendGrid, env-style assignments and connection strings. Each match becomes [redacted].++const EXTRA_SECRETS = [++ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,++ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,++ /\bAIza[0-9A-Za-z_-]{30,}/g,++ /\bnpm_[A-Za-z0-9]{30,}/g,++ /\bhf_[A-Za-z0-9]{30,}/g,++ /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g,++ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,++ /\b([A-Za-z0-9_.-]*(?:password|passwd|passphrase|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|credential|credentials|cookie|session))(\s*[:=]\s*)(["']?)[^\s"']{6,}\3/gi,++ /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:@\s/]+:[^@\s/]+@/gi,++];++++/** A text with every secret it names replaced; → { text, redacted }. */++function scrub(input) {++ let text = String(input == null ? '' : input);++ const before = text;++ text = harnessScrub(text);++ for (const re of EXTRA_SECRETS) {++ text = text.replace(re, (match, name, sep) => {++ if (typeof name === 'string' && typeof sep === 'string') return `${name}${sep}[redacted]`;++ if (/^-----BEGIN/.test(match)) return '[redacted]';++ if (/^[a-z]+:\/\//i.test(match)) return match.replace(/:\/\/.*$/, '://[redacted]');++ return '[redacted]';++ });++ }++ return { text, redacted: text !== before };++}++++/** One line of text, with control characters removed and the length capped. */++function clean(input, max = MAX_TEXT) {++ const text = String(input == null ? '' : input)++ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')++ .replace(/\r\n?/g, '\n')++ .trim();++ return text.length > max ? `${text.slice(0, max - 1)}…` : text;++}++++/** The repository a row is about, normalized for matching: host/owner/name, or the raw value when it is not a URL. */++function repoOf(input) {++ const raw = String(input == null ? '' : input).trim();++ if (!raw) return null;++ if (raw === 'bench://') return 'bench';++ const ssh = /^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):([^/\s]+\/[^/\s]+?)(?:\.git)?$/.exec(raw);++ if (ssh) return `${ssh[1].toLowerCase()}/${ssh[2].replace(/\.git$/i, '')}`;++ try {++ const url = new URL(raw);++ if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) return clean(raw, 200);++ const parts = url.pathname.split('/').filter(Boolean);++ if (parts.length >= 2) return `${url.hostname.toLowerCase()}/${parts.slice(0, 2).join('/').replace(/\.git$/i, '')}`;++ return `${url.hostname.toLowerCase()}${url.pathname.replace(/\.git$/i, '')}`;++ } catch {++ // A bare owner/name is a GitHub repository (the page's form and the API accept both shapes).++ return /^[\w.-]+\/[\w.-]+$/.test(raw) ? `github.com/${raw.replace(/\.git$/i, '')}` : clean(raw, 200);++ }++}++++const normalize = (text) => String(text || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w ./:-]+/g, '').trim();++++/** The key a repeated row is recognized by: the kind, repo and normalized text. */++function dedupeKey({ kind, text, repo }) {++ return crypto.createHash('sha256').update(`${kind}\u0000${repo || ''}\u0000${normalize(text)}`).digest('hex');++}++++const pathsOf = (paths) => [...new Set((Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean))].slice(0, 12);++++/** The paths a text names, the way the portal's lessons store reads them (a small, useful signal). */++function pathsIn(text) {++ const value = String(text || '');++ const found = new Set();++ // A path with at least one folder: src/app.js, openvibe-shared/serve.js.++ for (const m of value.matchAll(/(?:^|[\s`'"(])((?:[\w.-]+\/){1,6}[\w.-]+\.[A-Za-z0-9]{1,8})(?=[\s`'"),.:;]|$)/g)) found.add(m[1]);++ // A bare filename a run can act on (package.json, server.js); a version number is not a path.++ for (const m of value.matchAll(/(?:^|[\s`'"(])([\w.-]+\.(?:js|mjs|cjs|ts|json|md|go|py|rb|rs|java|kt|sql|yml|yaml|toml|sh|css|html|jsx|tsx))(?=[\s`'"),.:;]|$)/gi)) found.add(m[1]);++ return [...found].slice(0, 12);++}++++function asArray(value) {++ if (Array.isArray(value)) return value;++ if (typeof value === 'string') { try { const p = JSON.parse(value); return Array.isArray(p) ? p : []; } catch { return []; } }++ return [];++}++++const iso = (ms) => (Number.isFinite(Number(ms)) ? new Date(Number(ms)).toISOString() : null);++++/** What a row looks like outside the store. The owner's subject is not exposed: a reader knows whose row it is only++ * as "yours" or "a teammate's". */++function present(row, { subject } = {}) {++ if (!row) return null;++ return {++ id: row.id,++ kind: row.kind,++ text: row.text,++ repo: row.repo || null,++ paths: asArray(row.paths),++ pinned: !!row.pinned,++ visibility: row.visibility,++ team_id: row.team_id || null,++ mine: subject ? row.subject === subject : undefined,++ source: row.source_task ? { task: row.source_task, job: row.source_job || null, agent: row.source_agent || null } : null,++ seen: Number(row.seen) || 1,++ redacted: !!row.redacted,++ created_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ last_seen_at: iso(row.last_seen_at),++ };++}++++/** The lessons and facts a job's prompt gets, from the rows already found. */++function block(rows, { limit = 6 } = {}) {++ const picked = (Array.isArray(rows) ? rows : []).slice(0, Math.max(1, limit));++ if (!picked.length) return '';++ const lines = picked.map((row) => {++ const tags = [row.kind, row.pinned ? 'pinned' : null, row.repo || null].filter(Boolean).join(' · ');++ const day = String(iso(row.last_seen_at) || '').slice(0, 10);++ const text = clean(row.text, 320);++ return `- [${tags}${day ? ` · ${day}` : ''}] ${text}`;++ });++ const text = [++ '## What earlier runs learned (OpenVibe.Codes Knowledge)',++ '',++ 'These come from your own runs and your team’s, on this repository. They may be out of date: check the files.',++ '',++ ...lines,++ ].join('\n');++ return text.length > MAX_BLOCK ? `${text.slice(0, MAX_BLOCK - 1)}…` : text;++}++++/** The Learned: lines in an agent's report (the house rules' own block). */++function learnedLines(text) {++ const out = [];++ let on = false;++ for (const line of String(text || '').split(/\r?\n/)) {++ const inline = /^\s*Learned\s*:\s*(.*)$/i.exec(line);++ if (inline) {++ on = true;++ if (inline[1].trim()) out.push(inline[1].trim());++ continue;++ }++ if (!on) continue;++ if (!line.trim()) { if (out.length) break; continue; }++ const bullet = /^\s*(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line);++ if (bullet) out.push(bullet[1].trim());++ else if (/^\s+/.test(line) && out.length) out[out.length - 1] = `${out[out.length - 1]} ${line.trim()}`;++ else break;++ }++ return out.filter(Boolean).slice(0, 5);++}++++/** The Fact: lines in an agent's report. */++function factLines(text) {++ const out = [];++ for (const line of String(text || '').split(/\r?\n/)) {++ const m = /^\s*(?:[-*•]\s*)?Fact\s*:\s*(.+)$/i.exec(line);++ if (m) out.push(m[1].trim());++ }++ return out.filter(Boolean).slice(0, 5);++}++++/** A command a repository's runs use: tests, builds and installs, not one-off reads. */++const COMMAND = /^(?:npm|pnpm|yarn|bun|node|npx|pytest|python(?:3)? -m pytest|go test|cargo test|cargo build|make|just|deno test|php|ruby|bundle exec|mvn|gradle|dotnet test|mix test)\b/;++++function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log = console } = {}) {++ const db = store.db;++ const now = () => store.now();++++ /** The person's projects (their teams), from Network with their own token. A Network that cannot answer gives none. */++ async function projectsOf(token) {++ if (!token || !config.networkInternalUrl) return [];++ try {++ const res = await fetchImpl(`${String(config.networkInternalUrl).replace(/\/+$/, '')}/api/v1/projects`, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },++ signal: AbortSignal.timeout(8000),++ });++ if (!res.ok) return [];++ const doc = await res.json().catch(() => null);++ return (Array.isArray(doc && doc.projects) ? doc.projects : [])++ .filter((p) => p && p.id && !p.archived_at)++ .map((p) => ({ id: p.id, name: p.name || p.id, role: p.role || null }));++ } catch (e) {++ log.warn(`[Codes] knowledge: Network did not answer the projects: ${String((e && e.message) || e).slice(0, 160)}`);++ return [];++ }++ }++++ const teamIdsOf = async (token) => (await projectsOf(token)).map((p) => p.id);++++ /**++ * The rows a person may read: their own, plus the team-shared rows of the teams named. With teamIds null, only++ * private rows are read (an internal caller with no person token, e.g. a run started by a service).++ */++ async function list({ subject, teamIds = null, q = null, repo = null, kind = null, pinned = false, limit = 100 } = {}) {++ const teams = Array.isArray(teamIds) ? teamIds.filter(Boolean) : [];++ const max = Math.max(1, Math.min(500, Number(limit) || 100));++ return await db.many(++ `SELECT * FROM codes_knowledge++ WHERE (subject = $1 OR (visibility = 'team' AND team_id = ANY($2::text[])))++ AND ($3::text IS NULL OR kind = $3)++ AND ($4::boolean IS NOT TRUE OR pinned = true)++ AND ($5::text IS NULL OR repo = $5)++ AND ($6::text IS NULL OR position(lower($6) in lower(text)) > 0)++ ORDER BY pinned DESC, last_seen_at DESC, created_at DESC++ LIMIT $7`,++ [subject, teams, kind, !!pinned, repo, q ? String(q).slice(0, 200) : null, max],++ );++ }++++ /** One row the person may read, or null. */++ async function get({ subject, id, teamIds = [] }) {++ const teams = Array.isArray(teamIds) ? teamIds.filter(Boolean) : [];++ return await db.maybe(++ `SELECT * FROM codes_knowledge WHERE id = $1 AND (subject = $2 OR (visibility = 'team' AND team_id = ANY($3::text[])))`,++ [String(id || ''), subject, teams],++ );++ }++++ /** Add one row, or raise the one it repeats. → { row, created, redacted }. */++ async function remember({ subject, kind = 'lesson', text, repo = null, paths = [], sourceTask = null, sourceJob = null, sourceAgent = null, teamId = null, pinned = false, visibility = null } = {}) {++ const k = KINDS.includes(kind) ? kind : 'lesson';++ const cleaned = scrub(clean(text));++ if (!cleaned.text || cleaned.text === '[redacted]') {++ throw Object.assign(new Error('there is nothing to remember after removing the secrets'), { status: 422, code: 'knowledge.empty' });++ }++ const repository = repoOf(repo);++ const allPaths = [...new Set([...pathsOf(paths), ...pathsIn(cleaned.text)])].slice(0, 12);++ const at = now();++ const id = store.newId('kn');++ const share = visibility === 'team' && teamId ? { visibility: 'team', team_id: String(teamId) } : { visibility: 'private', team_id: null };++ const row = await db.one(++ `INSERT INTO codes_knowledge++ (id, subject, team_id, visibility, kind, text, repo, paths, source_task, source_job, source_agent,++ dedupe_key, seen, pinned, redacted, created_at, updated_at, last_seen_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, 1, $13, $14, $15, $15, $15)++ ON CONFLICT (subject, kind, dedupe_key) WHERE dedupe_key IS NOT NULL++ DO UPDATE SET seen = codes_knowledge.seen + 1,++ last_seen_at = EXCLUDED.last_seen_at,++ updated_at = EXCLUDED.updated_at,++ source_task = COALESCE(EXCLUDED.source_task, codes_knowledge.source_task),++ source_job = COALESCE(EXCLUDED.source_job, codes_knowledge.source_job),++ source_agent = COALESCE(EXCLUDED.source_agent, codes_knowledge.source_agent)++ RETURNING *, (xmax = 0) AS created`,++ [id, subject, share.team_id, share.visibility, k, cleaned.text, repository, JSON.stringify(allPaths),++ sourceTask ? String(sourceTask) : null, sourceJob ? String(sourceJob) : null, sourceAgent ? String(sourceAgent) : null,++ dedupeKey({ kind: k, text: cleaned.text, repo: repository }), !!pinned, cleaned.redacted, at],++ );++ return { row, created: !!row.created, redacted: cleaned.redacted };++ }++++ /** The owner's edit of one row: its text and kind (a new dedupe key follows the new text). */++ async function edit({ subject, id, text, kind, pinned } = {}) {++ const row = await db.maybe('SELECT * FROM codes_knowledge WHERE id = $1 AND subject = $2', [String(id || ''), subject]);++ if (!row) return null;++ const k = KINDS.includes(kind) ? kind : row.kind;++ const cleaned = scrub(clean(text == null ? row.text : text));++ if (!cleaned.text || cleaned.text === '[redacted]') {++ throw Object.assign(new Error('there is nothing to remember after removing the secrets'), { status: 422, code: 'knowledge.empty' });++ }++ const repository = repoOf(row.repo);++ return await db.one(++ `UPDATE codes_knowledge SET text = $2, kind = $3, dedupe_key = $4, redacted = $5,++ pinned = COALESCE($6::boolean, pinned), updated_at = $7++ WHERE id = $1 RETURNING *`,++ [row.id, cleaned.text, k, dedupeKey({ kind: k, text: cleaned.text, repo: repository }), cleaned.redacted,++ typeof pinned === 'boolean' ? pinned : null, now()],++ );++ }++++ /** Pin or unpin the owner's row. */++ async function pin({ subject, id, pinned = true } = {}) {++ return await db.maybe('UPDATE codes_knowledge SET pinned = $3, updated_at = $4 WHERE id = $1 AND subject = $2 RETURNING *',++ [String(id || ''), subject, !!pinned, now()]);++ }++++ /** The owner's row, gone. */++ async function remove({ subject, id } = {}) {++ return await db.maybe('DELETE FROM codes_knowledge WHERE id = $1 AND subject = $2 RETURNING *', [String(id || ''), subject]);++ }++++ /** Share the owner's row with one of their teams. The route has already checked the person is a member. */++ async function share({ subject, id, teamId } = {}) {++ return await db.maybe(`UPDATE codes_knowledge SET visibility = 'team', team_id = $3, updated_at = $4++ WHERE id = $1 AND subject = $2 RETURNING *`, [String(id || ''), subject, String(teamId || ''), now()]);++ }++++ /** The owner's row, private again. */++ async function unshare({ subject, id } = {}) {++ return await db.maybe(`UPDATE codes_knowledge SET visibility = 'private', team_id = NULL, updated_at = $3++ WHERE id = $1 AND subject = $2 RETURNING *`, [String(id || ''), subject, now()]);++ }++++ /**++ * What one finished run taught, captured idempotently: the agent's Learned: and Fact: lines, a failure with its++ * reason, the command that failed, and the commands the repository's runs use. Never the whole transcript.++ */++ async function captureTask(row) {++ if (!row || !row.id || !row.subject) return [];++ const result = row.result && typeof row.result === 'object' ? row.result : {};++ const logLines = Array.isArray(result.log) ? result.log : [];++ const summary = String(result.summary || '');++ const texts = [summary, ...logLines.filter((l) => l && (l.t === 'text' || l.t === 'done')).map((l) => String(l.text || ''))];++ const agent = (Array.isArray(result.agents) && result.agents[0]) || row.agent || null;++ const repo = repoOf(row.repo);++ const day = new Date(now()).toISOString().slice(0, 10);++ const entries = [];++++ for (const text of texts) {++ for (const line of learnedLines(text)) entries.push({ kind: 'lesson', text: line });++ for (const line of factLines(text)) entries.push({ kind: 'fact', text: line });++ }++++ const failed = result.ok === false || (row.state && row.state !== 'succeeded');++ if (failed) {++ const why = clean(String(row.error || result.error || summary || 'no error was reported').split(/\r?\n/)[0], 320);++ entries.push({ kind: 'lesson', text: `${repo || 'This workspace'}: ${agent || 'an agent'} did not finish — ${why}` });++ // The command that failed: the tool result keeps the id of the tool call that ran it.++ const commands = new Map();++ for (const line of logLines) {++ if (line && line.t === 'tool' && line.id && line.input && line.input.command) commands.set(String(line.id), String(line.input.command));++ }++ const bad = [...logLines].reverse().find((l) => l && l.t === 'tool_result' && l.ok === false && String(l.output || '').trim());++ if (bad) {++ const command = commands.get(String(bad.id || ''));++ const output = clean(String(bad.output || '').split(/\r?\n/).slice(0, 3).join(' '), 240);++ entries.push({ kind: 'lesson', text: `${repo || 'This repository'}: \`${command || 'a command'}\` failed — ${output}` });++ }++ }++++ // The commands this repository's runs use, as facts: tests, builds and installs, at most two.++ const seenCommands = new Set();++ for (const line of logLines) {++ const command = line && line.t === 'tool' && line.input && line.input.command ? String(line.input.command).trim() : '';++ if (!command || !COMMAND.test(command) || seenCommands.has(command)) continue;++ seenCommands.add(command);++ entries.push({ kind: 'fact', text: `${repo || 'This workspace'}: \`${clean(command, 160)}\` is a command its runs use (ran by ${agent || 'an agent'} on ${day}).` });++ if (seenCommands.size >= 2) break;++ }++++ const out = [];++ for (const entry of entries.slice(0, MAX_CAPTURE)) {++ try {++ const saved = await remember({++ subject: row.subject, kind: entry.kind, text: entry.text, repo, paths: pathsIn(entry.text),++ sourceTask: row.id, sourceJob: row.job_id || null, sourceAgent: agent,++ });++ out.push(saved.row);++ } catch (e) {++ if (!e || e.code !== 'knowledge.empty') log.warn(`[Codes] knowledge capture for ${row.id}: ${String((e && e.message) || e).slice(0, 160)}`);++ }++ }++ return out;++ }++++ /** Rank the rows a next run should see: pinned first, then this repository, then the words it shares with the brief. */++ function rank(rows, { repo, prompt } = {}) {++ const words = new Set(String(prompt || '').toLowerCase().match(/[a-z0-9_./-]{4,}/g) || []);++ const at = now();++ return (Array.isArray(rows) ? rows : [])++ .map((row) => {++ let score = 0;++ if (row.pinned) score += 10;++ if (repo && row.repo === repo) score += 4;++ const text = String(row.text || '').toLowerCase();++ for (const w of words) if (text.includes(w)) score += 1;++ const days = Math.max(0, (at - Number(row.last_seen_at || 0)) / 86400000);++ score += Math.max(0, 3 - days / 7);++ return { row, score };++ })++ .filter((x) => x.score > 0 && (x.row.pinned || (repo && x.row.repo === repo) || x.score > 3))++ .sort((a, b) => b.score - a.score || Number(b.row.last_seen_at) - Number(a.row.last_seen_at) || String(a.row.id).localeCompare(String(b.row.id)))++ .map((x) => x.row);++ }++++ /**++ * The rows the next run's prompt gets. `projectId` is the run's own project: its team-shared rows are read without++ * asking Network again (the run was started in that project, so the person belongs to it).++ */++ async function recall({ subject, projectId = null, repo = null, prompt = '', limit = 6 } = {}) {++ const teams = projectId && String(projectId).startsWith('prj_') ? [projectId] : [];++ const rows = await list({ subject, teamIds: teams, limit: 200 });++ const picked = rank(rows, { repo: repoOf(repo), prompt }).slice(0, Math.max(1, Math.min(8, limit)));++ return { rows: picked, text: block(picked, { limit: picked.length }) };++ }++++ return {++ projectsOf, teamIdsOf, list, get, remember, edit, pin, remove, share, unshare, captureTask, recall, rank,++ block, present: (row, opts) => present(row, opts), scrub, repoOf, learnedLines, factLines, KINDS,++ };++}++++module.exports = {++ createKnowledge, block, scrub, repoOf, learnedLines, factLines, dedupeKey, pathsIn, present, KINDS, MAX_TEXT,++};@@@ -147,7 +147,7 @@ function usageOf(result) {};}--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null }) {++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null }) {const db = store.db;const who = (row) => ({ subject: row.subject, project: row.project_id });// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).@@@ -543,6 +543,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouelse refusal = attemptStore.whyNoAgent(f);}const runArgs = { ...args, agent, model };++ // Know K5: the lessons and facts earlier runs learned, in the prompt this run's harness gets. A store that++ // cannot be read is skipped, never an error: a run must not fail because the memory is unavailable. The++ // project the run belongs to also scopes the team rows it may read.++ if (knowledge && runArgs.repo && runArgs.repo !== 'bench://') {++ try {++ const recalled = await knowledge.recall({ subject, projectId: project, repo: runArgs.repo, prompt: runArgs.prompt });++ const text = knowledge.block(recalled.rows);++ if (text) runArgs.knowledge = text;++ } catch (e) {++ log.warn(`[Codes] task ${id}: knowledge could not be recalled: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ }const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;// Placement A1: the task's kind, its harness, and why the placer put it there. The person's rules, Bench priors// and learned counts are read once here; the placement itself is pure. A task whose task kind is review is@@@ -707,6 +719,14 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst attempts = await attemptStore.list(row.id);const ending = attempts[attempts.length - 1];if (!ending || !ENDED.has(ending.state)) return row;++ // Know K5: what this attempt taught (its Learned: lines, a failure, the command that failed, the commands the++ // repository uses) is stored before the next attempt is planned, so the next run can recall it. The capture is++ // idempotent per (subject, kind, text), so a redelivered ending adds no second row.++ if (knowledge) {++ try { await knowledge.captureTask(row); } catch (e) {++ log.warn(`[Codes] task ${row.id}: knowledge could not be captured: ${String((e && e.message) || e).slice(0, 200)}`);++ }++ }// The proof this attempt's machine collected: the items are kept, and each one is an event on the stream.const collected = await recordProof(row, ending);const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });@@@ -38,6 +38,8 @@ const BUDGETS = {// The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's// own repositories come first and cost nothing; this bounds what they may spend on the rest.'codes.repo.search': { minute: 6, hour: 60 },++ // An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.++ 'codes.knowledge.write': { minute: 30, hour: 600 },};/**@@@ -845,6 +845,9 @@ ${conductorSection}// module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).r.use(require('./memory').createMemoryRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));++ // The Knowledge page (Know K5): the lessons and facts the person's own runs taught, with the run each came from.++ r.use(require('./knowledge').createKnowledgeRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));++// The guided setup (/setup): machine, agents, keys, accounts and choices, each step read live. Its home-page card// reads the database only, through ctx.setupProgress (the chat app's home asks it).const setupRoutes = require('./setup').createSetupRoutes(ctx, {@@@ -192,6 +192,7 @@ function createChatRoutes(ctx) {place('Machines', '/machines', 'machines'),place('OpenVibe Git', '/git', 'repo'),place('Memory', '/memory', 'memory'),++ place('Knowledge', '/knowledge', 'proof'),place('Usage', '/usage', 'usage'),...(waitingIds.size ? [{ href: '/approvals', label: 'Approvals', icon: 'hand', here: here === '/approvals', count: waitingIds.size }] : []),place('Settings', '/settings', 'settings'),nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++++/**++ * The knowledge API for the person's own agents (Know K5): the `ov-know` MCP server reads and writes these rows with++ * the person's own Network token. A cookie session works too (the page uses its own routes, not these).++ *++ * GET /api/v1/knowledge?q=&repo=&kind=&pinned=&limit= → { knowledge: […], count, teams_not_read? }++ * POST /api/v1/knowledge { text, kind?, repo?, paths?, pinned?, team_id? } → { knowledge, created } (201)++ *++ * Every read is the person's own rows plus the rows their teams shared; the team list comes from Network with the++ * caller's token, so no client can name a team it does not belong to. Every write is redacted before it is stored.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++++function createKnowledgeApi({ config, store, knowledge, actorLimits, log = console }) {++ const r = asyncRouter();++ const json = express.json({ limit: '32kb' });++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const sameOrigin = (req) => {++ const origin = req.get('origin');++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ };++++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++++ r.get('/knowledge', actorLimits.reads('codes.knowledge.read'), async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to read your knowledge');++ const token = req.token || (req.viewer && req.viewer.token) || null;++ let teams = [];++ let teamsNotRead = false;++ try {++ teams = await knowledge.teamIdsOf(token);++ } catch {++ teamsNotRead = true;++ }++ const kind = ['lesson', 'fact'].includes(String(req.query.kind || '')) ? String(req.query.kind) : null;++ const rows = await knowledge.list({++ subject: req.viewer.subject,++ teamIds: teams,++ q: req.query.q ? String(req.query.q).slice(0, 200) : null,++ repo: req.query.repo ? knowledge.repoOf(String(req.query.repo).slice(0, 300)) : null,++ kind,++ pinned: req.query.pinned === '1' || req.query.pinned === 'true',++ limit: Math.max(1, Math.min(100, Number(req.query.limit) || 50)),++ });++ return res.json({++ knowledge: rows.map((row) => knowledge.present(row, { subject: req.viewer.subject })),++ count: rows.length,++ ...(teamsNotRead ? { teams_not_read: true } : {}),++ });++ });++++ r.post('/knowledge', actorLimits.budget('codes.knowledge.write'), json, async (req, res) => {++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to add knowledge');++ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site posts are refused');++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const text = typeof b.text === 'string' ? b.text : '';++ if (!text.trim()) return problem(req, res, 422, 'knowledge.text_required', 'text is required');++ if (!knowledge.KINDS.includes(b.kind || 'lesson')) return problem(req, res, 422, 'knowledge.kind_invalid', `kind is one of ${knowledge.KINDS.join(', ')}`);++ if (b.repo != null && (typeof b.repo !== 'string' || b.repo.length > 300)) return problem(req, res, 422, 'knowledge.repo_invalid', 'repo must be a string of at most 300 characters');++ if (b.paths != null && (!Array.isArray(b.paths) || b.paths.length > 12 || b.paths.some((p) => typeof p !== 'string' || p.length > 300))) {++ return problem(req, res, 422, 'knowledge.paths_invalid', 'paths is at most 12 strings of 300 characters');++ }++ let teamId = null;++ if (b.team_id != null && b.team_id !== '') {++ const teams = await knowledge.projectsOf(req.token || (req.viewer && req.viewer.token) || null);++ if (!teams.some((t) => t.id === String(b.team_id))) return problem(req, res, 403, 'knowledge.team_forbidden', 'you are not a member of that team');++ teamId = String(b.team_id);++ }++ try {++ const out = await knowledge.remember({++ subject: req.viewer.subject,++ kind: b.kind || 'lesson',++ text,++ repo: b.repo || null,++ paths: b.paths || [],++ pinned: b.pinned === true,++ teamId,++ visibility: teamId ? 'team' : 'private',++ sourceAgent: 'agent',++ });++ log.info(`[Codes] knowledge: ${req.viewer.subject} ${out.created ? 'added' : 'repeated'} ${out.row.id}${teamId ? ' (shared)' : ''}`);++ return res.status(out.created ? 201 : 200).json({ knowledge: knowledge.present(out.row, { subject: req.viewer.subject }), created: out.created });++ } catch (e) {++ return problem(req, res, e.status || 422, e.code || 'knowledge.invalid', e.message);++ }++ });++++ return r;++}++++module.exports = { createKnowledgeApi };nnew file mode 100644@@@ -0,0 +1,246 @@++'use strict';++++/**++ * The Knowledge page (Know K5): what a person's own coding runs taught them, and what their team shared.++ *++ * The rows live in Codes' own table (server/domain/knowledge.js). Every row is visible, editable, pinnable and++ * deletable here, with the run it came from; private by default, shareable with a team (a Network project the person++ * belongs to). The page is server-rendered and complete without JavaScript: the edit and share panels are details++ * elements, and every write is a same-origin form post that answers with a flash back to the tab it came from.++ *++ * GET /knowledge the page: all, lessons, facts, pinned, team++ * POST /knowledge add a lesson or fact by hand++ * POST /knowledge/:id/edit its text and kind++ * POST /knowledge/:id/pin pin or unpin it++ * POST /knowledge/:id/delete the row, gone++ * POST /knowledge/:id/share share it with one of the person's teams++ * POST /knowledge/:id/unshare private again++ *++ * Secrets are scrubbed before the row is stored. Nothing here logs the text.++ */++++const express = require('express');++const { esc } = require('../render/html');++const { asyncRouter } = require('./router');++++const ID = /^kn_[0-9A-HJKMNP-TV-Z]{26}$/;++const TABS = [['all', 'All'], ['lessons', 'Lessons'], ['facts', 'Facts'], ['pinned', 'Pinned'], ['team', 'Team']];++++const dayOf = (value) => {++ const ms = Date.parse(String(value || ''));++ if (!Number.isFinite(ms)) return '';++ const d = new Date(ms);++ const now = new Date();++ if (d.toDateString() === now.toDateString()) return 'today';++ const sameYear = d.getFullYear() === now.getFullYear();++ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + (sameYear ? '' : ', ' + d.getFullYear());++};++const agoOf = (value) => {++ const ms = Date.parse(String(value || ''));++ if (!Number.isFinite(ms)) return '';++ const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));++ if (mins < 1) return 'just now';++ if (mins < 60) return `${mins} min ago`;++ if (mins < 1440) return `${Math.floor(mins / 60)} h ago`;++ return `${Math.floor(mins / 1440)} d ago`;++};++++function createKnowledgeRoutes(ctx, kit) {++ const { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note } = kit;++ const { knowledge } = ctx;++ const router = asyncRouter();++++ const back = (req, res, fallback, ok, error) => {++ const asked = String((req.body && req.body.tab) || req.query.tab || '');++ const tab = TABS.some(([id]) => id === asked) ? asked : fallback;++ const target = `/knowledge?tab=${encodeURIComponent(tab)}`;++ if (error) return res.redirect(303, `${target}&error=${encodeURIComponent(String(error).slice(0, 180))}`);++ return res.redirect(303, `${target}&ok=${encodeURIComponent(String(ok).slice(0, 140))}`);++ };++++ const teamsOf = async (req, res) => {++ try {++ return await ctx.sso.asViewer(req, res, (token) => knowledge.projectsOf(token));++ } catch {++ return [];++ }++ };++++ async function rowsOf(req, res) {++ let teams = [];++ try {++ teams = await ctx.sso.asViewer(req, res, (token) => knowledge.teamIdsOf(token));++ } catch { /* private rows only */ }++ const rows = await knowledge.list({ subject: req.viewer.subject, teamIds: teams, limit: 500 });++ return { rows, teams };++ }++++ const matches = (tab) => (row) => tab === 'all' ? true++ : tab === 'lessons' ? row.kind === 'lesson'++ : tab === 'facts' ? row.kind === 'fact'++ : tab === 'pinned' ? !!row.pinned++ : row.visibility === 'team';++++ function addForm(teams) {++ return `<details class="c4-more kn-add"><summary>Add a lesson or a fact</summary><div class="c4-more-body">++<form class="c4-form" method="post" action="/knowledge">++<input type="hidden" name="tab" value="all">++<label>What should the next run know?<textarea name="text" rows="3" maxlength="2000" required placeholder="The home test pins the sc-navbar markup (test/home.test.js)."></textarea></label>++<div class="c4-form-row">++<label>Kind<select name="kind"><option value="lesson">lesson</option><option value="fact">fact</option></select></label>++<label>Repository <span class="c4-muted">(optional)</span><input name="repo" maxlength="300" placeholder="OpenVibers/OpenVibe.Codes"></label>++</div>++${teams.length ? `<label>Share with <select name="team_id"><option value="">Just me</option>${teams.map((t) => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></label>` : '<p class="c4-hint">Private to you. Share it with a team once you belong to one.</p>'}++<button class="c4-button" type="submit">Remember this</button>++</form></div></details>`;++ }++++ function rowHtml(row, { mine, teams, tab }) {++ const team = row.team_id ? teams.find((t) => t.id === row.team_id) : null;++ // The repository is where a lesson applies, said with the source line rather than floating in the head.++ const where = row.repo ? ` · <span class="kn-repo">${esc(String(row.repo).replace(/^(?:https?:\/\/)?(?:www\.)?github\.com\//i, ''))}</span>` : '';++ const source = row.source_task++ ? `<p class="kn-source">From <a href="/tasks/${esc(row.source_task)}">the run that learned it</a>${row.source_agent ? ` · ${esc(row.source_agent)}` : ''}${where}</p>`++ : `<p class="kn-source">Added by you${where}</p>`;++ const shared = row.visibility === 'team'++ ? pill(`Shared${team ? ` with ${team.name}` : ''}`, 'ok')++ : pill('Private');++ const actions = mine ? `<div class="kn-actions">++<form method="post" action="/knowledge/${esc(row.id)}/pin"><input type="hidden" name="tab" value="${esc(tab)}"><input type="hidden" name="pinned" value="${row.pinned ? '0' : '1'}"><button class="c4-button c4-button-light" type="submit">${row.pinned ? 'Unpin' : 'Pin'}</button></form>++<details class="c4-more"><summary>Edit</summary><div class="c4-more-body">++<form class="c4-form" method="post" action="/knowledge/${esc(row.id)}/edit">++<input type="hidden" name="tab" value="${esc(tab)}">++<label>Text<textarea name="text" rows="3" maxlength="2000" required>${esc(row.text)}</textarea></label>++<label>Kind<select name="kind"><option value="lesson"${row.kind === 'lesson' ? ' selected' : ''}>Lesson</option><option value="fact"${row.kind === 'fact' ? ' selected' : ''}>Fact</option></select></label>++<button class="c4-button" type="submit">Save</button>++</form></div></details>++${row.visibility === 'team' ? `<form method="post" action="/knowledge/${esc(row.id)}/unshare"><input type="hidden" name="tab" value="${esc(tab)}"><button class="c4-button c4-button-light" type="submit">Make private</button></form>`++ : (teams.length ? `<details class="c4-more"><summary>Share</summary><div class="c4-more-body"><form class="c4-form" method="post" action="/knowledge/${esc(row.id)}/share"><input type="hidden" name="tab" value="${esc(tab)}"><label>Team<select name="team_id">${teams.map((t) => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></label><button class="c4-button" type="submit">Share with this team</button></form></div></details>`++ : '<span class="c4-hint">No team to share with yet.</span>')}++<details class="c4-more kn-delete"><summary>Delete</summary><div class="c4-more-body"><p class="kn-confirm">Delete this ${row.kind === 'fact' ? 'fact' : 'lesson'}? Runs stop recalling it, and it cannot be brought back.</p><form method="post" action="/knowledge/${esc(row.id)}/delete"><input type="hidden" name="tab" value="${esc(tab)}"><button class="c4-button c4-button-danger" type="submit">Delete it</button></form></div></details>++</div>` : `<p class="kn-team-note">${row.visibility === 'team' ? 'Shared with your team.' : ''} Only ${mine ? 'you' : 'its owner'} can edit it.</p>`;++ return `<article class="c4-card kn-row${row.pinned ? ' is-pinned' : ''}">++<div class="kn-row-head">${pill(row.kind === 'fact' ? 'Fact' : 'Lesson')}${row.pinned ? pill('Pinned', 'pinned') : ''}${shared}</div>++<p class="kn-text">${esc(row.text)}</p>++<p class="kn-meta">${esc(dayOf(row.last_seen_at))}${row.seen > 1 ? ` · seen ${row.seen} times` : ''}${row.redacted ? ' · secrets removed' : ''}</p>++${source}${actions}++</article>`;++ }++++ async function knowledgePage(req, res) {++ const title = 'Knowledge';++ const head = header(title, 'What your runs learned, ready for the next one. Private until you share it.');++ if (!viewer(req)) return page(req, res, title, wrap(head + signIn(req)));++ const asked = String(req.query.tab || '');++ const tab = TABS.some(([id]) => id === asked) ? asked : 'all';++ let rows = [];++ let teams = [];++ let problem = null;++ try {++ const out = await rowsOf(req, res);++ rows = out.rows;++ teams = await teamsOf(req, res);++ } catch (e) {++ problem = e;++ }++ const mine = rows.filter((r) => r.subject === req.viewer.subject);++ const counts = {++ all: rows.length,++ lessons: rows.filter((r) => r.kind === 'lesson').length,++ facts: rows.filter((r) => r.kind === 'fact').length,++ pinned: rows.filter((r) => r.pinned).length,++ team: rows.filter((r) => r.visibility === 'team').length,++ };++ const shown = rows.filter(matches(tab));++ const tabs = `<nav class="c4-tabs" aria-label="Knowledge">${TABS.map(([id, label]) => {++ const n = counts[id];++ return `<a href="/knowledge?tab=${id}"${id === tab ? ' aria-current="page"' : ''}>${esc(`${label} (${n})`)}</a>`;++ }).join('')}</nav>`;++ const summary = `<div class="kn-summary"><div><strong>${mine.length}</strong><span>yours</span></div><div><strong>${counts.pinned}</strong><span>pinned</span></div><div><strong>${counts.team}</strong><span>shared</span></div></div>`;++ const empty = `<section class="c4-card c4-empty"><h2>${tab === 'all' ? 'Nothing learned yet' : 'Nothing here yet'}</h2><p>Run a task and Codes stores what it learned: what failed and why, the repository's commands, its traps. Every row is yours to edit, pin or delete.</p><p class="c4-hint">The first run has nothing to recall; the second one starts with it.</p></section>`;++ const content = `${head}${flash(req)}${problem ? note('Your knowledge could not be read right now. Try again in a moment.', 'bad') : ''}++${summary}++<p class="c4-hint">Private to you by default. Share a row with a team and its members can read it in their own runs; only you can edit or delete it.</p>++${addForm(teams)}++${tabs}++<div class="kn-list">${shown.length ? shown.map((row) => rowHtml(row, { mine: row.subject === req.viewer.subject, teams, tab })).join('') : empty}</div>`;++ return page(req, res, title, wrap(content));++ }++++ router.get('/knowledge', knowledgePage);++++ router.post('/knowledge', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const b = req.body || {};++ try {++ const teams = await teamsOf(req, res);++ const teamId = b.team_id ? String(b.team_id) : '';++ if (teamId && !teams.some((t) => t.id === teamId)) throw Object.assign(new Error('you are not a member of that team'), { status: 403 });++ const out = await knowledge.remember({++ subject: req.viewer.subject, kind: b.kind, text: b.text, repo: b.repo || null,++ teamId: teamId || null, visibility: teamId ? 'team' : 'private',++ });++ return back(req, res, 'all', out.created ? 'Remembered.' : 'That was already here; it now counts one more sighting.');++ } catch (e) {++ return back(req, res, 'all', '', e.message || 'That row could not be saved.');++ }++ });++++ router.post('/knowledge/:id/edit', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');++ try {++ const row = await knowledge.edit({ subject: req.viewer.subject, id: req.params.id, text: req.body.text, kind: req.body.kind });++ if (!row) return back(req, res, 'all', '', 'There is no such row of yours.');++ return back(req, res, 'all', 'Saved.');++ } catch (e) {++ return back(req, res, 'all', '', e.message || 'That row could not be saved.');++ }++ });++++ router.post('/knowledge/:id/pin', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');++ const pinned = String((req.body && req.body.pinned) || '') === '1';++ const row = await knowledge.pin({ subject: req.viewer.subject, id: req.params.id, pinned });++ return back(req, res, String((req.body && req.body.tab) || 'all'), row ? (pinned ? 'Pinned.' : 'Unpinned.') : '');++ });++++ router.post('/knowledge/:id/delete', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');++ const row = await knowledge.remove({ subject: req.viewer.subject, id: req.params.id });++ return back(req, res, 'all', row ? 'Deleted.' : '', row ? '' : 'There is no such row of yours.');++ });++++ router.post('/knowledge/:id/share', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');++ const teamId = String((req.body && req.body.team_id) || '');++ try {++ const teams = await teamsOf(req, res);++ if (!teams.some((t) => t.id === teamId)) throw new Error('you are not a member of that team');++ const row = await knowledge.share({ subject: req.viewer.subject, id: req.params.id, teamId });++ return back(req, res, 'all', row ? 'Shared with your team.' : '', row ? '' : 'There is no such row of yours.');++ } catch (e) {++ return back(req, res, 'all', '', e.message || 'That row could not be shared.');++ }++ });++++ router.post('/knowledge/:id/unshare', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');++ const row = await knowledge.unshare({ subject: req.viewer.subject, id: req.params.id });++ return back(req, res, 'all', row ? 'Private again.' : '', row ? '' : 'There is no such row of yours.');++ });++++ return router;++}++++module.exports = { createKnowledgeRoutes, TABS, dayOf, agoOf };@@@ -19,8 +19,9 @@ const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the person's// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor--// with its approvals inbox (Codes C9b) and the fleet their machines report (Codes C9c).--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet)(\/|$)/;++// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge++// their own agents read and add (Know K5).++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);@@@ -42,6 +42,7 @@ const WORKSPACE_NAV = [{ label: 'Agents', href: '/agents', icon: 'fa-robot' },{ label: 'Machines', href: '/machines', icon: 'fa-display' },{ label: 'Memory', href: '/memory', icon: 'fa-brain' },++ { label: 'Knowledge', href: '/knowledge', icon: 'fa-book' },{ label: 'Usage', href: '/usage', icon: 'fa-chart-line' },{ label: 'Settings', href: '/settings', icon: 'fa-gear' },];@@@ -72,7 +73,7 @@ function renderPage(o) {const pathNow = o.path || '/';// The workspace pages share the compact Frame: no second app bar under the Frame's own (a query must not change it).const pathOnly = pathNow.split('?')[0];-- const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|usage|settings)(\/|$)/.test(pathOnly);++ const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|knowledge|usage|settings)(\/|$)/.test(pathOnly);const loginNext = encodeURIComponent(pathNow);const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with@@@ -58,6 +58,8 @@ async function startNetworkStub() {await db.exec("INSERT INTO codes_notices (task_id, kind) VALUES ($1, 'done')", [taskId]);await db.exec(`INSERT INTO codes_machine_fleet (subject, machine, name, agent, installed, signed_in, windows, online, reported_at)VALUES ($1, 'dev_laptop', 'laptop', 'codex', true, true, '[]'::jsonb, true, now())`, [subject]);++ await db.exec(`INSERT INTO codes_knowledge (id, subject, kind, text, repo, dedupe_key, created_at, updated_at, last_seen_at)++ VALUES ($1, $2, 'lesson', $3, 'github.com/a/b', $4, 1, 1, 1)`, [`kn_${ids.ulid()}`, subject, `${subject} learned this (README.md)`, ids.ulid()]);}const deliver = async (event, { secret = SECRET } = {}) => {const body = JSON.stringify({ event, seq: 1 });@@@ -111,6 +113,10 @@ async function startNetworkStub() {assert.ok(fleet, 'fleet.json is in the export');assert.strictEqual(fleet.content.length, 1, 'Ada’s fleet only');assert.strictEqual(fleet.content[0].agent, 'codex');++ const kn = part.body.files.find((x) => x.name === 'knowledge.json');++ assert.ok(kn, 'knowledge.json is in the export');++ assert.strictEqual(kn.content.length, 1, 'Ada’s knowledge only');++ assert.match(kn.content[0].text, /learned this \(README\.md\)/);});await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {@@@ -127,6 +133,7 @@ async function startNetworkStub() {['codes_attempts', 'task_id', [boTask]], ['codes_task_proof', 'task_id', [boTask]], ['codes_events_inbox', 'task_id', [boTask]],['codes_notices', 'task_id', [boTask]], ['codes_machine_fleet', 'subject', [bo]],['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],++ ['codes_knowledge', 'subject', [bo]],]) {const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);assert.deepStrictEqual(left, who, `${table}: only Bo’s row is left`);nnew file mode 100644@@@ -0,0 +1,255 @@++'use strict';++++/**++ * Know K5: workspace knowledge on Codes.++ *++ * a run's ending is captured (its Learned: lines, a failure, the command that failed, the commands it uses), with++ * secrets removed; the next run's job carries it in its prompt; every row is visible, editable, pinnable and++ * deletable on /knowledge with its source run; a row is private until its owner shares it with a team, and only++ * that team's members (not everyone) can read it; the API the ov-know MCP speaks answers the same rows.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { execFileSync } = require('child_process');++const { boot, check, done } = require('./helpers/boot');++const { parseArgs, agentPrompt, runJob } = require('../harness/job');++const secrets = require('./helpers/fakesecrets');++++const REPO = 'https://github.com/OpenVibers/OpenVibe.Codes';++const PROJECT = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** A small repository to clone, as a file:// URL. */++function repository() {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-kn-src-'));++ const sh = (...args) => execFileSync('git', args, {++ cwd: dir, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' },++ });++ sh('init', '-q', '-b', 'main');++ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n');++ sh('add', '-A');++ sh('commit', '-q', '-m', 'first');++ return { dir, url: `file://${dir}` };++}++++/** A stand-in harness that records the prompt the job actually hands it. */++function fakeHarness() {++ const calls = [];++ return {++ calls,++ run(opts) {++ calls.push(opts);++ return (async function* run() {++ yield { type: 'system', subtype: 'init', agent: 'claude-code', model: 'test' };++ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Done.' }] } };++ yield { type: 'result', is_error: false, result: 'Done.\n\nLearned:\n- It worked (README.md).', total_cost_usd: 0.001 };++ })();++ },++ };++}++++(async () => {++ const calls = [];++ const jobs = new Map();++ let counter = 0;++ const run = {++ enabled: true,++ async submit(o) {++ counter++;++ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2K${String(counter).padStart(2, '0')}`;++ jobs.set(id, { id, state: 'queued', args: o.args });++ calls.push(o);++ return { id, state: 'queued' };++ },++ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },++ async cancel(jobId) { const job = jobs.get(jobId); if (job) job.state = 'cancelled'; return job; },++ async streamUrl() { throw new Error('not used'); },++ };++ const t = await boot({ run });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const carol = t.network.addUser('carol');++ t.network.state.projects.set(PROJECT, {++ id: PROJECT, name: 'The team', owner: ada.subject, environment_policy: 'sandbox', allowance: [],++ created_at: new Date().toISOString(), archived_at: null,++ });++ t.network.state.members.set(PROJECT, new Map([[ada.subject, 'owner'], [bo.subject, 'developer']]));++++ const startTask = async (prompt) => await t.ctx.tasks.start({ subject: ada.subject, project: PROJECT, args: parseArgs(JSON.stringify({ repo: REPO, prompt, agent: 'claude-code' })) });++ const rowOf = async (id) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);++ const end = async (id, state, result, error = null) => {++ const row = await rowOf(id);++ const updated = await t.ctx.tasks.applyEvent(row, { state, job: { id: row.job_id, state, result, error: null }, error });++ await t.ctx.tasks.afterEnd(updated.id);++ return await rowOf(id);++ };++ const knowledgeOf = async (subject) => await db.many('SELECT * FROM codes_knowledge WHERE subject = $1 ORDER BY created_at ASC', [subject]);++++ try {++ let firstId = null;++ let captured = null;++++ await check('a finished run is captured: its Learned: lines, the failure, the failed command and the commands it used', async () => {++ firstId = (await startTask('Fix the home test after the pin bump.')).id;++ assert.match(firstId, TASK_ID);++ const provider = secrets.providerKey();++ const password = secrets.password();++ const result = {++ ok: true,++ summary: `I finished.\n\nLearned:\n- The home test pins the sc-navbar markup (test/home.test.js).\n- The token ${provider} must not be stored; ${password}.`,++ agents: ['codex'],++ log: [++ { t: 'tool', id: 't1', name: 'Bash', input: { command: 'npm test' } },++ { t: 'tool_result', id: 't1', ok: true, output: '2 tests passed' },++ { t: 'text', text: 'I finished.\n\nLearned:\n- The home test pins the sc-navbar markup (test/home.test.js).' },++ ],++ };++ const ended = await end(firstId, 'succeeded', result);++ assert.strictEqual(ended.state, 'succeeded');++ // The failure path is the same capture, from an attempt that did not finish; call it with that ending.++ await t.ctx.knowledge.captureTask({++ ...ended, state: 'failed', error: 'the tests failed',++ result: {++ ok: false, summary: 'I could not finish.', agents: ['codex'],++ log: [++ { t: 'tool', id: 't1', name: 'Bash', input: { command: 'npm test' } },++ { t: 'tool_result', id: 't1', ok: false, output: 'Error: 2 tests failed\n at home.test.js:42' },++ ],++ },++ });++ captured = await knowledgeOf(ada.subject);++ assert.ok(captured.length >= 4, `${captured.length} rows captured`);++ const learned = captured.find((r) => /sc-navbar markup/.test(r.text));++ assert.ok(learned, 'the Learned: line is stored');++ assert.strictEqual(learned.source_task, firstId, 'the row names the run it came from');++ assert.strictEqual(learned.kind, 'lesson');++ const heldSecret = captured.find((r) => /\[redacted\]/.test(r.text));++ assert.ok(heldSecret, 'the row that held a key is stored with the key replaced');++ assert.ok(heldSecret.redacted, 'and is marked redacted');++ const all = captured.map((r) => r.text).join('\n');++ assert.ok(!all.includes(provider) && !all.includes(secrets.passwordValue()), 'the provider key and password are gone');++ assert.match(all, /\[redacted\]/);++ assert.ok(captured.some((r) => r.kind === 'fact' && /npm test/.test(r.text)), 'the command the repository uses is a fact');++ assert.ok(captured.some((r) => /npm test` failed/.test(r.text)), 'the command that failed is a lesson');++ assert.ok(captured.every((r) => r.visibility === 'private'), 'captured rows are private by default');++ });++++ await check('the next run’s job carries them, and the harness prompt gets them before the brief', async () => {++ const second = await startTask('Fix the pin bump bundle growth in package.json.');++ assert.match(second.id, TASK_ID);++ const submitted = calls.at(-1);++ assert.ok(submitted.args.knowledge, 'the job input carries the knowledge block');++ assert.match(submitted.args.knowledge, /sc-navbar markup/);++ const parsed = parseArgs(JSON.stringify(submitted.args));++ assert.match(parsed.knowledge, /OpenVibe\.Codes Knowledge/);++ const prompt = agentPrompt(parsed);++ assert.ok(prompt.startsWith(parsed.knowledge), 'the knowledge is the prompt’s first section');++ assert.ok(prompt.includes('Fix the pin bump bundle growth'), 'the person’s brief follows it');++ assert.ok(prompt.includes('End with a `Learned:` block'), 'and the next run is asked to teach one back');++ // End it so the test leaves no queued task behind.++ await end(second.id, 'succeeded', { ok: true, summary: 'Done.', agents: ['claude-code'], log: [] });++ });++++ await check('a job’s prompt is what the harness receives, for every adapter', async () => {++ const src = repository();++ const harness = fakeHarness();++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-kn-tmp-'));++ try {++ const doc = await runJob({++ input: JSON.stringify({ repo: src.url, prompt: 'Add a line', knowledge: '## What earlier runs learned\n\n- Use npm test (package.json).\n\n---\n' }),++ harness, emit: () => {}, writeResult: () => {}, env: { ...process.env, OPENVIBE_CODES_ALLOW_FILE_REPO: '1' }, tmp,++ });++ assert.strictEqual(doc.ok, true, JSON.stringify(doc));++ assert.ok(harness.calls[0].prompt.startsWith('## What earlier runs learned'), 'the prefix reaches the adapter');++ assert.ok(harness.calls[0].prompt.includes('Add a line'));++ assert.ok(harness.calls[0].prompt.includes('Before you finish (OpenVibe.Codes Knowledge)'));++ } finally {++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ });++++ await check('the page shows every row with its source run, and its edit, pin, share and delete forms', async () => {++ const page = await t.get('/knowledge', { as: ada });++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ assert.ok(page.text.includes('Knowledge'), 'the page title');++ assert.ok(page.text.includes('The home test pins the sc-navbar markup'), 'the captured text');++ assert.ok(page.text.includes(`/tasks/${firstId}`), 'the source run links to the task');++ assert.ok(page.text.includes('Add a lesson or a fact'), 'the add form');++ assert.ok(page.text.includes('Pin'), 'pin');++ assert.ok(page.text.includes('>Edit<'), 'edit');++ assert.ok(page.text.includes('>Delete<') && page.text.includes('>Delete it<'), 'delete');++ assert.ok(page.text.includes('Share'), 'share');++ assert.ok(page.text.includes('The team'), 'the person’s team is offered');++ });++++ await check('a row can be edited, pinned, and deleted by its owner', async () => {++ const row = (await knowledgeOf(ada.subject)).find((r) => /sc-navbar markup/.test(r.text));++ const edited = await t.get(`/knowledge/${row.id}/edit`, { as: ada, form: { tab: 'all', text: 'The home test pins the sc-navbar markup (test/home.test.js); update it with a pin.', kind: 'fact' } });++ assert.strictEqual(edited.status, 303);++ const after = await db.one('SELECT * FROM codes_knowledge WHERE id = $1', [row.id]);++ assert.strictEqual(after.kind, 'fact');++ assert.match(after.text, /update it with a pin/);++ const pinned = await t.get(`/knowledge/${row.id}/pin`, { as: ada, form: { tab: 'all', pinned: '1' } });++ assert.strictEqual(pinned.status, 303);++ assert.strictEqual((await db.one('SELECT pinned FROM codes_knowledge WHERE id = $1', [row.id])).pinned, true);++ const deleted = await t.get(`/knowledge/${row.id}/delete`, { as: ada, form: { tab: 'all' } });++ assert.strictEqual(deleted.status, 303);++ assert.strictEqual(await db.maybe('SELECT 1 FROM codes_knowledge WHERE id = $1', [row.id]), null);++ });++++ await check('a row is shared with a team: its members read it, a non-member does not, and only the owner edits it', async () => {++ const shared = await t.ctx.knowledge.remember({++ subject: ada.subject, kind: 'lesson', text: 'Team only: the deploy waits for the release tag (release.json).', repo: 'OpenVibers/OpenVibe.Codes',++ });++ const share = await t.get(`/knowledge/${shared.row.id}/share`, { as: ada, form: { tab: 'all', team_id: PROJECT } });++ assert.strictEqual(share.status, 303);++ const row = await db.one('SELECT * FROM codes_knowledge WHERE id = $1', [shared.row.id]);++ assert.strictEqual(row.visibility, 'team');++ assert.strictEqual(row.team_id, PROJECT);++++ const boPage = await t.get('/knowledge', { as: bo });++ assert.ok(boPage.text.includes('Team only: the deploy waits'), 'a team member reads it');++ assert.ok(boPage.text.includes('Shared with The team'), 'and it says why');++ const carolPage = await t.get('/knowledge', { as: carol });++ assert.ok(!carolPage.text.includes('Team only: the deploy waits'), 'a non-member does not');++ const boEdit = await t.get(`/knowledge/${row.id}/edit`, { as: bo, form: { tab: 'all', text: 'hijacked', kind: 'lesson' } });++ assert.strictEqual(boEdit.status, 303);++ assert.match((await db.one('SELECT text FROM codes_knowledge WHERE id = $1', [row.id])).text, /deploy waits/, 'a teammate cannot edit it');++ const boDelete = await t.get(`/knowledge/${row.id}/delete`, { as: bo, form: { tab: 'all' } });++ assert.strictEqual(boDelete.status, 303);++ assert.ok(await db.maybe('SELECT 1 FROM codes_knowledge WHERE id = $1', [row.id]), 'and cannot delete it');++ });++++ await check('the API the ov-know MCP speaks reads the rows, adds a redacted row, and refuses another person', async () => {++ const read = await t.get('/api/v1/knowledge', { bearer: t.network.userToken(ada) });++ assert.strictEqual(read.status, 200, read.text.slice(0, 200));++ assert.ok(read.json().knowledge.length >= 2, 'the person’s rows');++ assert.ok(read.json().knowledge.every((r) => typeof r.mine === 'boolean'));++ const secret = secrets.providerKey();++ const added = await t.get('/api/v1/knowledge', {++ method: 'POST', bearer: t.network.userToken(ada),++ json: { text: `A fact with a key ${secret} (src/app.js).`, kind: 'fact', repo: 'OpenVibers/OpenVibe.Codes' },++ });++ assert.strictEqual(added.status, 201, added.text.slice(0, 200));++ assert.ok(!added.json().knowledge.text.includes(secret), 'the answer is redacted');++ assert.match(added.json().knowledge.text, /\[redacted\]/);++ const boRead = await t.get('/api/v1/knowledge', { bearer: t.network.userToken(bo) });++ assert.ok(boRead.json().knowledge.some((r) => /deploy waits/.test(r.text)), 'a teammate reads the shared row');++ const anon = await t.get('/api/v1/knowledge');++ assert.strictEqual(anon.status, 401);++ });++++ await check('the person’s knowledge is exported with the account and erased with it', async () => {++ const { TABLES } = require('../server/account-data');++ assert.ok(TABLES.some((x) => x.table === 'codes_knowledge' && x.subject === 'subject'), 'the table is covered');++ const before = Number(await db.value('SELECT count(*) FROM codes_knowledge WHERE subject = $1', [ada.subject]));++ assert.ok(before > 0);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -19,8 +19,8 @@ const BUDGETS = {jsRawKB: 357.8, // 357.8 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 355.8 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 354.5 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 353.2 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 349.9 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 348.7 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 345.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 342.2 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 341.4 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 340.6 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 85.9, // 85.9 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 85.6 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 85.3 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 84.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.-- cssRawKB: 75, // 74.7 after the conductor failover: the fallback rows in Settings, the usage card, a switched turn (public/css/c4.css, 1.3 KB, 2026-11-03); 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.-- cssBrotliKB: 15.5, // 15.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.++ cssRawKB: 78, // 77.1 with Know K5: the Knowledge page's rows, add form, source line and actions (public/css/c4.css, 1.9 KB, 2026-11-06); 74.7 after the conductor failover: the fallback rows in Settings, the usage card, a switched turn (public/css/c4.css, 1.3 KB, 2026-11-03); 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.++ cssBrotliKB: 16.0, // 15.7 with Know K5's Knowledge page; 15.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.externalFiles: 2, // 0};@@@ -56,6 +56,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person','server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person','server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',++ 'server/domain/knowledge.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, to read the teams a knowledge row may be shared with; the path is fixed and no URL from a person is fetched','server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path','server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person','server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
@@ -21,6 +21,7 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -21,6 +21,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
+| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| Task API (services and people) | `POST /api/v1/tasks`, `GET /api/v1/tasks`, `GET /api/v1/tasks/:id`, `GET /api/v1/tasks/:id/events`, `POST /api/v1/tasks/:id/continue`, `POST /api/v1/tasks/:id/stop`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`; `permission` is `review` (read-only), `edit` or `full`. A person may call the same routes with their own Network access token (`openvibe-codes task`, [docs/cli.md](docs/cli.md)): their token names them, so they see and act on every task they own, whoever started it. `GET /api/v1/tasks?state=running|needs|done` lists them. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404); the owner needs no token for their own tasks. The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. **A task's attempts** (stage C2, migration 0010 `codes_attempts`): every Run job is one attempt, so a continue, a switch to another agent and a wrap-up are attempts on one timeline, never new tasks. `GET /api/v1/tasks/:id` answers `codes.task@2`: the task's own fields at the top level, exactly as `codes.task@1` had them, and, added to them, `attempts[]` (the timeline) and `outcome` — `done`, `unfinished` (with what is left) or `stopped`. `GET /api/v1/tasks/:id/events` is the task's event stream (server-sent events): the session's lines in order with the attempt each belongs to, the attempts themselves (started, ended with why), a heartbeat every 15 s, and a resume with `Last-Event-ID`. `POST /api/v1/tasks/:id/continue` starts the next attempt now (with `agent` it switches), `POST /api/v1/tasks/:id/stop` stops the running attempt and nothing carries it on. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
@@ -109,6 +110,7 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
@@ -109,6 +110,7 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
`npm test` (development: `fnm exec --using=22.22.1 npm test`) runs every `test/*.test.js` in its own process ([test/run.js](test/run.js)), each on a temp PGlite database with in-process mocks of OpenVibe.Network, Events and Media ([test/helpers/boot.js](test/helpers/boot.js)); `CODES_TEST_STORE=pg` runs the same against the containers. The main files, one line each:
`npm test` (development: `fnm exec --using=22.22.1 npm test`) runs every `test/*.test.js` in its own process ([test/run.js](test/run.js)), each on a temp PGlite database with in-process mocks of OpenVibe.Network, Events and Media ([test/helpers/boot.js](test/helpers/boot.js)); `CODES_TEST_STORE=pg` runs the same against the containers. The main files, one line each:
- [test/harness-run.test.js](test/harness-run.test.js) — every CLI adapter turns its agent's output into the shared events with one result; availability comes from PATH and keys; routing leaves out what is not here and says why; a failed or silent attempt hands the task on; redaction; the local run store.
- [test/harness-run.test.js](test/harness-run.test.js) — every CLI adapter turns its agent's output into the shared events with one result; availability comes from PATH and keys; routing leaves out what is not here and says why; a failed or silent attempt hands the task on; redaction; the local run store.
+- [test/knowledge.test.js](test/knowledge.test.js) — a run's ending is captured (Learned: lines, a failure, the failed command, the repository's commands) with secrets redacted; the next run's prompt gets them; every row is visible, editable, pinnable and deletable with its source run; a team member reads a shared row and a non-member does not; the person's token API answers the ov-know MCP; the row is exported and erased with the account.
- [test/harness-api.test.js](test/harness-api.test.js) — the API agent loop against a stand-in OpenAI-compatible server: tools confined to the working directory, permission levels, `.env`/`.git`/outside paths refused, an HTTP error ends the run, resume continues the conversation.
- [test/harness-api.test.js](test/harness-api.test.js) — the API agent loop against a stand-in OpenAI-compatible server: tools confined to the working directory, permission levels, `.env`/`.git`/outside paths refused, an HTTP error ends the run, resume continues the conversation.
- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.
- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.
- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.
- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.
@@ -227,6 +227,9 @@ function parseArgs(raw, env = process.env) {
@@ -227,6 +227,9 @@ function parseArgs(raw, env = process.env) {
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
...(effort ? { effort } : {}),
...(effort ? { effort } : {}),
+ // Know K5: the lessons and facts earlier runs learned, from the server's own store and already a short block.
+ // It is prepended to the prompt in runJob, so every harness (CLI or API) gets it the same way.
+ ...(typeof a.knowledge === 'string' && a.knowledge.trim() ? { knowledge: a.knowledge.trim().slice(0, 4000) } : {}),
// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
};
};
@@ -575,6 +578,21 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {
@@ -575,6 +578,21 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {
return out;
return out;
}
}
+/**
+ * The prompt the agent gets: the knowledge block the server recalled (when there is one), the person's brief, and — for
+ * a code task — the one line that lets the next run learn from this one. Every adapter takes the prompt as its brief,
+ * so this is the same for a CLI harness and an API model.
+ */
+function agentPrompt(job) {
+ const parts = [];
+ if (job.knowledge) parts.push(job.knowledge);
+ parts.push(job.prompt);
+ if (job.kind === 'code') {
+ parts.push('---\n## Before you finish (OpenVibe.Codes Knowledge)\n\nEnd with a `Learned:` block of at most three lines: one fact the next agent on this repository should know, each with the file, command or URL that shows it. Leave it out when nothing was new.');
+ }
+ return parts.join('\n\n');
+}
+
/**
/**
* Run one job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.
* Run one job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.
* → the result document.
* → the result document.
@@ -660,7 +678,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -660,7 +678,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
}
}
const run = harness.run({
const run = harness.run({
- prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null, cwd: work,
+ prompt: agentPrompt(job), task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null, cwd: work,
permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
// A job on the person's Node runs in its sandbox (own namespaces, a read-only root, no home folder, only
// A job on the person's Node runs in its sandbox (own namespaces, a read-only root, no home folder, only
// what the job was given); the Node sets OPENVIBE_JOB_ID there. Only inside it may an agent with no
// what the job was given); the Node sets OPENVIBE_JOB_ID there. Only inside it may an agent with no
@@ -748,4 +766,4 @@ function resultWriter(emit) {
@@ -748,4 +766,4 @@ function resultWriter(emit) {
};
};
}
}
-module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments };
+module.exports = { CONDUCTOR_AGENTS, conductorMcpLauncher, opencodeConfig, gitAuthFor, credentialFor, giteaHosts, pushForge, cloneProblem, runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, jobEnv, ENV_NAMES, JOB_PROTOCOL, codesToken, conductorMcpConfig, writeAttachments, agentPrompt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+-- phase: expand
+-- Know K5: a person's workspace knowledge — the lessons and facts their own runs taught them.
+--
+-- A row belongs to one person. It is private unless the person shares it with a team (a Network developer project,
+-- the same prj_… a run belongs to): visibility 'team' plus team_id makes it readable by that project's members.
+-- The text is scrubbed of secrets before it is written here; source_task points at the run that taught it.
+--
+-- A capture is idempotent: (subject, kind, dedupe_key) is unique, so running the same capture twice raises `seen`
+-- and moves `last_seen_at` instead of writing a second row. Nothing in this table is a token or a key.
+
+CREATE TABLE IF NOT EXISTS codes_knowledge (
+ id text COLLATE "C" PRIMARY KEY, -- kn_<ULID>
+ subject text COLLATE "C" NOT NULL, -- usr_… who owns the row
+ team_id text COLLATE "C", -- prj_… when shared with a team
+ visibility text COLLATE "C" NOT NULL DEFAULT 'private'
+ CHECK (visibility IN ('private', 'team')),
+ kind text COLLATE "C" NOT NULL DEFAULT 'lesson'
+ CHECK (kind IN ('lesson', 'fact')),
+ text text NOT NULL, -- redacted before it is stored
+ repo text, -- host/owner/name, or the task's repository
+ paths jsonb NOT NULL DEFAULT '[]'::jsonb, -- files the row names
+ source_task text COLLATE "C", -- tsk_… the run it came from
+ source_job text COLLATE "C", -- Run's job_…
+ source_agent text, -- the harness that learned it
+ dedupe_key text COLLATE "C", -- sha256 of the normalized text
+ seen integer NOT NULL DEFAULT 1,
+ pinned boolean NOT NULL DEFAULT false,
+ redacted boolean NOT NULL DEFAULT false,
+ created_at bigint NOT NULL,
+ updated_at bigint NOT NULL,
+ last_seen_at bigint NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS codes_knowledge_subject ON codes_knowledge (subject, created_at DESC);
+CREATE INDEX IF NOT EXISTS codes_knowledge_team ON codes_knowledge (team_id, created_at DESC) WHERE team_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS codes_knowledge_source ON codes_knowledge (source_task) WHERE source_task IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS codes_knowledge_dedupe ON codes_knowledge (subject, kind, dedupe_key)
+ WHERE dedupe_key IS NOT NULL;
@@ -318,3 +318,49 @@
@@ -318,3 +318,49 @@
.c4-turn-switch{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 11px;border-radius:8px;background:var(--c4-open-bg);color:var(--c4-open-ink);font-size:12px;line-height:1.45}
.c4-turn-switch{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:9px 11px;border-radius:8px;background:var(--c4-open-bg);color:var(--c4-open-ink);font-size:12px;line-height:1.45}
.c4-turn-switch svg{width:15px;height:15px;flex:none;margin-top:1px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.c4-turn-switch svg{width:15px;height:15px;flex:none;margin-top:1px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
@media (max-width:640px){.c4-fallback{grid-template-columns:28px minmax(0,1fr)}.c4-fallback>label:last-child{grid-column:2}}
@media (max-width:640px){.c4-fallback{grid-template-columns:28px minmax(0,1fr)}.c4-fallback>label:last-child{grid-column:2}}
+
+/* Know K5: the Knowledge page. A row is a card whose head carries its kind and sharing, then the text, where it came
+ from, and the actions that change it. All of it is server-rendered; the details open in place. */
+.kn-summary{display:flex;gap:18px;margin:0 0 14px}
+.kn-summary>div{display:flex;align-items:baseline;gap:6px;font-size:12px;color:var(--c4-muted)}
+.kn-summary strong{font-size:20px;color:var(--c4-ink)}
+.kn-add{margin:0 0 14px;border:1px solid var(--c4-line);border-radius:10px;padding:0 12px;background:var(--c4-surface)}
+.kn-add>summary{padding:10px 0}
+.kn-add .c4-more-body{padding-bottom:12px}
+.kn-list{display:grid;gap:12px}
+.kn-row{padding:14px 16px;border-left:3px solid transparent}
+.kn-row.is-pinned{border-left-color:var(--c4-accent)}
+.kn-row-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px;min-width:0}
+.kn-repo{overflow-wrap:anywhere}
+.kn-text{margin:0 0 8px;font-size:14px;line-height:1.55;color:var(--c4-ink);overflow-wrap:anywhere;white-space:pre-wrap}
+.kn-meta{margin:0 0 4px;font-size:12px;color:var(--c4-muted)}
+.kn-source{margin:0 0 10px;font-size:12px;color:var(--c4-muted)}
+.kn-source a{color:var(--c4-accent)}
+.kn-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
+.kn-actions form{margin:0}
+.kn-actions .c4-button{min-height:36px;padding:6px 12px;font-size:12px}
+.kn-actions .c4-more{flex:0 0 auto;min-width:0}
+.kn-actions .c4-more>summary{min-height:36px;justify-content:center;border:1px solid var(--c4-line);border-radius:8px;padding:6px 12px}
+.kn-actions .c4-more-body{flex-basis:100%;width:100%}
+.kn-team-note{margin:0;font-size:12px;color:var(--c4-muted)}
+.c4-form-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr);gap:12px;align-items:end}
+@media(max-width:640px){
+ .kn-summary{gap:12px}
+ .c4-form-row{grid-template-columns:1fr}
+ .kn-actions .c4-button{min-height:44px}
+}
+/* The Knowledge page's own panels stay closed until opened, at every width (the Memory page's open-on-desktop
+ rule would show every edit and share form at once), and an opened one takes the row's full width. */
+.kn-add:not([open]) > .c4-more-body,.kn-actions .c4-more:not([open]) > .c4-more-body{display:none}
+.kn-add.c4-more > summary,.kn-actions .c4-more > summary{display:flex}
+.kn-actions .c4-more[open]{flex:1 1 100%;order:2}
+.kn-actions form{order:0}
+@media(max-width:640px){.c4-tabs a{padding-left:8px;padding-right:8px}}
+/* Delete is quiet until asked: a text-coloured fold at the end of the row, with the real button inside it, so five
+ rows do not show five filled red buttons and one tap never deletes. */
+.kn-actions .kn-delete{margin-left:auto}
+.kn-actions .kn-delete>summary{border-color:transparent;color:var(--c4-danger);background:none}
+.kn-actions .kn-delete[open]>summary{border-color:var(--c4-line)}
+.kn-actions .c4-more:not(.kn-delete)>summary{color:var(--c4-ink);font-weight:600}
+.kn-confirm{margin:0 0 8px;font-size:13px;color:var(--c4-ink)}
+@media(max-width:640px){.kn-actions .c4-more>summary{min-height:44px}}
@@ -45,6 +45,9 @@ const TABLES = [
@@ -45,6 +45,9 @@ const TABLES = [
{ table: 'codes_outbox', subject: 'subject', file: 'events-sent.json', order: 'created_at', columns: ['event_id', 'topic', 'created_at', 'published_at'] },
{ table: 'codes_outbox', subject: 'subject', file: 'events-sent.json', order: 'created_at', columns: ['event_id', 'topic', 'created_at', 'published_at'] },
// The OpenVibe Git link: which forge account, and since when. The sealed tokens never leave; erased with the rest.
// The OpenVibe Git link: which forge account, and since when. The sealed tokens never leave; erased with the rest.
{ table: 'codes_forge_links', subject: 'subject', file: 'openvibe-git-link.json', order: 'created_at', columns: ['forge_user', 'expires_at', 'created_at', 'updated_at'] },
{ table: 'codes_forge_links', subject: 'subject', file: 'openvibe-git-link.json', order: 'created_at', columns: ['forge_user', 'expires_at', 'created_at', 'updated_at'] },
+ // Know K5: the lessons and facts the person's runs taught. Theirs to take and to erase; a row already shared
+ // with a team leaves with its owner's account (the team's other rows stay).
+ { table: 'codes_knowledge', subject: 'subject', file: 'knowledge.json', order: 'created_at', columns: ['id', 'kind', 'text', 'repo', 'paths', 'visibility', 'team_id', 'pinned', 'redacted', 'seen', 'source_task', 'source_agent', 'created_at', 'updated_at', 'last_seen_at'] },
];
];
// The rows that hang under a person's tasks: exported under the task, erased before it.
// The rows that hang under a person's tasks: exported under the task, erased before it.
@@ -47,6 +47,7 @@ const { createOutbox, createPublisher } = require('./events/outbox');
@@ -47,6 +47,7 @@ const { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createEventsConsumer } = require('./events-consumer');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createMeApi } = require('./http/me-api');
const { createMeApi } = require('./http/me-api');
+const { createKnowledgeApi } = require('./http/knowledge-api');
const { createMovedRoutes } = require('./http/moved');
const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
@@ -54,6 +55,7 @@ const { createForgejoClient } = require('./domain/forgejo');
@@ -54,6 +55,7 @@ const { createForgejoClient } = require('./domain/forgejo');
const createAiCatalogConfig = require('./ai-catalog-config').create;
const createAiCatalogConfig = require('./ai-catalog-config').create;
const { createMachines } = require('./domain/machines');
const { createMachines } = require('./domain/machines');
const { createMemory } = require('./domain/memory');
const { createMemory } = require('./domain/memory');
+const { createKnowledge } = require('./domain/knowledge');
const { createFleet } = require('./domain/fleet');
const { createFleet } = require('./domain/fleet');
const { createNotices } = require('./domain/notices');
const { createNotices } = require('./domain/notices');
const { createUserSettings } = require('./domain/user-settings');
const { createUserSettings } = require('./domain/user-settings');
@@ -110,8 +112,10 @@ async function createApp(opts = {}) {
@@ -110,8 +112,10 @@ async function createApp(opts = {}) {
const placementOutcomes = opts.placementOutcomes || createPlacementOutcomes({ store, now: store.now, log });
const placementOutcomes = opts.placementOutcomes || createPlacementOutcomes({ store, now: store.now, log });
const benchPriors = opts.benchPriors || createBenchPriors({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log, ttlMs: config.bench.priorsTtlMs });
const benchPriors = opts.benchPriors || createBenchPriors({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log, ttlMs: config.bench.priorsTtlMs });
const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });
const placementContext = createPlacementContext({ store, config, userSettings, outcomes: placementOutcomes, priors: benchPriors, harnesses, now: store.now });
- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext };
+ // Know K5: the lessons and facts a person's runs teach, private by default and shareable with a team.
+ const knowledge = createKnowledge({ store, config, fetchImpl: fetchImpl || globalThis.fetch, log });
+ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
+ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge };
ctx.forgejo = createForgejoClient({ config, log });
ctx.forgejo = createForgejoClient({ config, log });
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@ -252,6 +256,8 @@ async function createApp(opts = {}) {
@@ -252,6 +256,8 @@ async function createApp(opts = {}) {
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
+ // Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
+ app.use('/api/v1', createKnowledgeApi({ config, store, knowledge: ctx.knowledge, actorLimits: ctx.actorLimits, log }));
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createConnectionsApi({ config, store, connections, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createActionsApi({ config, store, keys, run, tasks, actorLimits: ctx.actorLimits, log }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,427 @@
@@ -0,0 +1,427 @@
+'use strict';
+
+/**
+ * Know K5: workspace knowledge on OpenVibe.Codes.
+ *
+ * The lessons and facts a person's own coding runs taught them (what failed and why, the repository's commands, its
+ * traps), stored here and nowhere else. A row is private to the person unless they share it with a team — a Network
+ * developer project, the same prj_… a run belongs to. A shared row is readable by the project's members and by nobody
+ * else; only its owner may edit, pin, share or delete it.
+ *
+ * Secrets never reach the table: every text is scrubbed before it is written (server/domain/knowledge scrubs with the
+ * same provider-key, GitHub-token, JWT, private-key and key=value patterns the harness uses, plus the shapes a person's
+ * files carry). A capture is idempotent per (subject, kind, dedupe key), so the same ending seen twice raises `seen`
+ * and moves the row's date instead of writing a second one.
+ *
+ * projectsOf(token) the person's projects (teams), from Network with their own token
+ * list({ subject, teamIds, … }) the rows the person may read
+ * remember({ … }) add one row (redacted, deduped), or raise the one it repeats
+ * edit/pin/remove/share/unshare the owner's changes
+ * captureTask(row) what a finished run taught: its Learned: lines, a failure, a failed command
+ * recall({ subject, projectId, … }) the few rows the next run's prompt gets
+ * block(rows) those rows as the prompt's own section
+ *
+ * A store that cannot be reached gives no rows, never an error: a run must not fail because memory is unavailable.
+ */
+
+const crypto = require('crypto');
+const { scrub: harnessScrub } = require('../../engine/redact');
+
+const KINDS = ['lesson', 'fact'];
+const MAX_TEXT = 2000;
+const MAX_BLOCK = 1600;
+const MAX_CAPTURE = 6;
+
+// The shapes a coding tool's output carries that the harness scrubber does not name yet: Slack, AWS, Google, npm,
+// Hugging Face, SendGrid, env-style assignments and connection strings. Each match becomes [redacted].
+const EXTRA_SECRETS = [
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
+ /\bAIza[0-9A-Za-z_-]{30,}/g,
+ /\bnpm_[A-Za-z0-9]{30,}/g,
+ /\bhf_[A-Za-z0-9]{30,}/g,
+ /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g,
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
+ /\b([A-Za-z0-9_.-]*(?:password|passwd|passphrase|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|credential|credentials|cookie|session))(\s*[:=]\s*)(["']?)[^\s"']{6,}\3/gi,
+ /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:@\s/]+:[^@\s/]+@/gi,
+];
+
+/** A text with every secret it names replaced; → { text, redacted }. */
+function scrub(input) {
+ let text = String(input == null ? '' : input);
+ const before = text;
+ text = harnessScrub(text);
+ for (const re of EXTRA_SECRETS) {
+ text = text.replace(re, (match, name, sep) => {
+ if (typeof name === 'string' && typeof sep === 'string') return `${name}${sep}[redacted]`;
+ if (/^-----BEGIN/.test(match)) return '[redacted]';
+ if (/^[a-z]+:\/\//i.test(match)) return match.replace(/:\/\/.*$/, '://[redacted]');
+ return '[redacted]';
+ });
+ }
+ return { text, redacted: text !== before };
+}
+
+/** One line of text, with control characters removed and the length capped. */
+function clean(input, max = MAX_TEXT) {
+ const text = String(input == null ? '' : input)
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
+ .replace(/\r\n?/g, '\n')
+ .trim();
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
+}
+
+/** The repository a row is about, normalized for matching: host/owner/name, or the raw value when it is not a URL. */
+function repoOf(input) {
+ const raw = String(input == null ? '' : input).trim();
+ if (!raw) return null;
+ if (raw === 'bench://') return 'bench';
+ const ssh = /^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):([^/\s]+\/[^/\s]+?)(?:\.git)?$/.exec(raw);
+ if (ssh) return `${ssh[1].toLowerCase()}/${ssh[2].replace(/\.git$/i, '')}`;
+ try {
+ const url = new URL(raw);
+ if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) return clean(raw, 200);
+ const parts = url.pathname.split('/').filter(Boolean);
+ if (parts.length >= 2) return `${url.hostname.toLowerCase()}/${parts.slice(0, 2).join('/').replace(/\.git$/i, '')}`;
+ return `${url.hostname.toLowerCase()}${url.pathname.replace(/\.git$/i, '')}`;
+ } catch {
+ // A bare owner/name is a GitHub repository (the page's form and the API accept both shapes).
+ return /^[\w.-]+\/[\w.-]+$/.test(raw) ? `github.com/${raw.replace(/\.git$/i, '')}` : clean(raw, 200);
+ }
+}
+
+const normalize = (text) => String(text || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w ./:-]+/g, '').trim();
+
+/** The key a repeated row is recognized by: the kind, repo and normalized text. */
+function dedupeKey({ kind, text, repo }) {
+ return crypto.createHash('sha256').update(`${kind}\u0000${repo || ''}\u0000${normalize(text)}`).digest('hex');
+}
+
+const pathsOf = (paths) => [...new Set((Array.isArray(paths) ? paths : []).map((p) => String(p || '').trim()).filter(Boolean))].slice(0, 12);
+
+/** The paths a text names, the way the portal's lessons store reads them (a small, useful signal). */
+function pathsIn(text) {
+ const value = String(text || '');
+ const found = new Set();
+ // A path with at least one folder: src/app.js, openvibe-shared/serve.js.
+ for (const m of value.matchAll(/(?:^|[\s`'"(])((?:[\w.-]+\/){1,6}[\w.-]+\.[A-Za-z0-9]{1,8})(?=[\s`'"),.:;]|$)/g)) found.add(m[1]);
+ // A bare filename a run can act on (package.json, server.js); a version number is not a path.
+ for (const m of value.matchAll(/(?:^|[\s`'"(])([\w.-]+\.(?:js|mjs|cjs|ts|json|md|go|py|rb|rs|java|kt|sql|yml|yaml|toml|sh|css|html|jsx|tsx))(?=[\s`'"),.:;]|$)/gi)) found.add(m[1]);
+ return [...found].slice(0, 12);
+}
+
+function asArray(value) {
+ if (Array.isArray(value)) return value;
+ if (typeof value === 'string') { try { const p = JSON.parse(value); return Array.isArray(p) ? p : []; } catch { return []; } }
+ return [];
+}
+
+const iso = (ms) => (Number.isFinite(Number(ms)) ? new Date(Number(ms)).toISOString() : null);
+
+/** What a row looks like outside the store. The owner's subject is not exposed: a reader knows whose row it is only
+ * as "yours" or "a teammate's". */
+function present(row, { subject } = {}) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ kind: row.kind,
+ text: row.text,
+ repo: row.repo || null,
+ paths: asArray(row.paths),
+ pinned: !!row.pinned,
+ visibility: row.visibility,
+ team_id: row.team_id || null,
+ mine: subject ? row.subject === subject : undefined,
+ source: row.source_task ? { task: row.source_task, job: row.source_job || null, agent: row.source_agent || null } : null,
+ seen: Number(row.seen) || 1,
+ redacted: !!row.redacted,
+ created_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ last_seen_at: iso(row.last_seen_at),
+ };
+}
+
+/** The lessons and facts a job's prompt gets, from the rows already found. */
+function block(rows, { limit = 6 } = {}) {
+ const picked = (Array.isArray(rows) ? rows : []).slice(0, Math.max(1, limit));
+ if (!picked.length) return '';
+ const lines = picked.map((row) => {
+ const tags = [row.kind, row.pinned ? 'pinned' : null, row.repo || null].filter(Boolean).join(' · ');
+ const day = String(iso(row.last_seen_at) || '').slice(0, 10);
+ const text = clean(row.text, 320);
+ return `- [${tags}${day ? ` · ${day}` : ''}] ${text}`;
+ });
+ const text = [
+ '## What earlier runs learned (OpenVibe.Codes Knowledge)',
+ '',
+ 'These come from your own runs and your team’s, on this repository. They may be out of date: check the files.',
+ '',
+ ...lines,
+ ].join('\n');
+ return text.length > MAX_BLOCK ? `${text.slice(0, MAX_BLOCK - 1)}…` : text;
+}
+
+/** The Learned: lines in an agent's report (the house rules' own block). */
+function learnedLines(text) {
+ const out = [];
+ let on = false;
+ for (const line of String(text || '').split(/\r?\n/)) {
+ const inline = /^\s*Learned\s*:\s*(.*)$/i.exec(line);
+ if (inline) {
+ on = true;
+ if (inline[1].trim()) out.push(inline[1].trim());
+ continue;
+ }
+ if (!on) continue;
+ if (!line.trim()) { if (out.length) break; continue; }
+ const bullet = /^\s*(?:[-*•]|\d+[.)])\s+(.*)$/.exec(line);
+ if (bullet) out.push(bullet[1].trim());
+ else if (/^\s+/.test(line) && out.length) out[out.length - 1] = `${out[out.length - 1]} ${line.trim()}`;
+ else break;
+ }
+ return out.filter(Boolean).slice(0, 5);
+}
+
+/** The Fact: lines in an agent's report. */
+function factLines(text) {
+ const out = [];
+ for (const line of String(text || '').split(/\r?\n/)) {
+ const m = /^\s*(?:[-*•]\s*)?Fact\s*:\s*(.+)$/i.exec(line);
+ if (m) out.push(m[1].trim());
+ }
+ return out.filter(Boolean).slice(0, 5);
+}
+
+/** A command a repository's runs use: tests, builds and installs, not one-off reads. */
+const COMMAND = /^(?:npm|pnpm|yarn|bun|node|npx|pytest|python(?:3)? -m pytest|go test|cargo test|cargo build|make|just|deno test|php|ruby|bundle exec|mvn|gradle|dotnet test|mix test)\b/;
+
+function createKnowledge({ store, config = {}, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const db = store.db;
+ const now = () => store.now();
+
+ /** The person's projects (their teams), from Network with their own token. A Network that cannot answer gives none. */
+ async function projectsOf(token) {
+ if (!token || !config.networkInternalUrl) return [];
+ try {
+ const res = await fetchImpl(`${String(config.networkInternalUrl).replace(/\/+$/, '')}/api/v1/projects`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) return [];
+ const doc = await res.json().catch(() => null);
+ return (Array.isArray(doc && doc.projects) ? doc.projects : [])
+ .filter((p) => p && p.id && !p.archived_at)
+ .map((p) => ({ id: p.id, name: p.name || p.id, role: p.role || null }));
+ } catch (e) {
+ log.warn(`[Codes] knowledge: Network did not answer the projects: ${String((e && e.message) || e).slice(0, 160)}`);
+ return [];
+ }
+ }
+
+ const teamIdsOf = async (token) => (await projectsOf(token)).map((p) => p.id);
+
+ /**
+ * The rows a person may read: their own, plus the team-shared rows of the teams named. With teamIds null, only
+ * private rows are read (an internal caller with no person token, e.g. a run started by a service).
+ */
+ async function list({ subject, teamIds = null, q = null, repo = null, kind = null, pinned = false, limit = 100 } = {}) {
+ const teams = Array.isArray(teamIds) ? teamIds.filter(Boolean) : [];
+ const max = Math.max(1, Math.min(500, Number(limit) || 100));
+ return await db.many(
+ `SELECT * FROM codes_knowledge
+ WHERE (subject = $1 OR (visibility = 'team' AND team_id = ANY($2::text[])))
+ AND ($3::text IS NULL OR kind = $3)
+ AND ($4::boolean IS NOT TRUE OR pinned = true)
+ AND ($5::text IS NULL OR repo = $5)
+ AND ($6::text IS NULL OR position(lower($6) in lower(text)) > 0)
+ ORDER BY pinned DESC, last_seen_at DESC, created_at DESC
+ LIMIT $7`,
+ [subject, teams, kind, !!pinned, repo, q ? String(q).slice(0, 200) : null, max],
+ );
+ }
+
+ /** One row the person may read, or null. */
+ async function get({ subject, id, teamIds = [] }) {
+ const teams = Array.isArray(teamIds) ? teamIds.filter(Boolean) : [];
+ return await db.maybe(
+ `SELECT * FROM codes_knowledge WHERE id = $1 AND (subject = $2 OR (visibility = 'team' AND team_id = ANY($3::text[])))`,
+ [String(id || ''), subject, teams],
+ );
+ }
+
+ /** Add one row, or raise the one it repeats. → { row, created, redacted }. */
+ async function remember({ subject, kind = 'lesson', text, repo = null, paths = [], sourceTask = null, sourceJob = null, sourceAgent = null, teamId = null, pinned = false, visibility = null } = {}) {
+ const k = KINDS.includes(kind) ? kind : 'lesson';
+ const cleaned = scrub(clean(text));
+ if (!cleaned.text || cleaned.text === '[redacted]') {
+ throw Object.assign(new Error('there is nothing to remember after removing the secrets'), { status: 422, code: 'knowledge.empty' });
+ }
+ const repository = repoOf(repo);
+ const allPaths = [...new Set([...pathsOf(paths), ...pathsIn(cleaned.text)])].slice(0, 12);
+ const at = now();
+ const id = store.newId('kn');
+ const share = visibility === 'team' && teamId ? { visibility: 'team', team_id: String(teamId) } : { visibility: 'private', team_id: null };
+ const row = await db.one(
+ `INSERT INTO codes_knowledge
+ (id, subject, team_id, visibility, kind, text, repo, paths, source_task, source_job, source_agent,
+ dedupe_key, seen, pinned, redacted, created_at, updated_at, last_seen_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, 1, $13, $14, $15, $15, $15)
+ ON CONFLICT (subject, kind, dedupe_key) WHERE dedupe_key IS NOT NULL
+ DO UPDATE SET seen = codes_knowledge.seen + 1,
+ last_seen_at = EXCLUDED.last_seen_at,
+ updated_at = EXCLUDED.updated_at,
+ source_task = COALESCE(EXCLUDED.source_task, codes_knowledge.source_task),
+ source_job = COALESCE(EXCLUDED.source_job, codes_knowledge.source_job),
+ source_agent = COALESCE(EXCLUDED.source_agent, codes_knowledge.source_agent)
+ RETURNING *, (xmax = 0) AS created`,
+ [id, subject, share.team_id, share.visibility, k, cleaned.text, repository, JSON.stringify(allPaths),
+ sourceTask ? String(sourceTask) : null, sourceJob ? String(sourceJob) : null, sourceAgent ? String(sourceAgent) : null,
+ dedupeKey({ kind: k, text: cleaned.text, repo: repository }), !!pinned, cleaned.redacted, at],
+ );
+ return { row, created: !!row.created, redacted: cleaned.redacted };
+ }
+
+ /** The owner's edit of one row: its text and kind (a new dedupe key follows the new text). */
+ async function edit({ subject, id, text, kind, pinned } = {}) {
+ const row = await db.maybe('SELECT * FROM codes_knowledge WHERE id = $1 AND subject = $2', [String(id || ''), subject]);
+ if (!row) return null;
+ const k = KINDS.includes(kind) ? kind : row.kind;
+ const cleaned = scrub(clean(text == null ? row.text : text));
+ if (!cleaned.text || cleaned.text === '[redacted]') {
+ throw Object.assign(new Error('there is nothing to remember after removing the secrets'), { status: 422, code: 'knowledge.empty' });
+ }
+ const repository = repoOf(row.repo);
+ return await db.one(
+ `UPDATE codes_knowledge SET text = $2, kind = $3, dedupe_key = $4, redacted = $5,
+ pinned = COALESCE($6::boolean, pinned), updated_at = $7
+ WHERE id = $1 RETURNING *`,
+ [row.id, cleaned.text, k, dedupeKey({ kind: k, text: cleaned.text, repo: repository }), cleaned.redacted,
+ typeof pinned === 'boolean' ? pinned : null, now()],
+ );
+ }
+
+ /** Pin or unpin the owner's row. */
+ async function pin({ subject, id, pinned = true } = {}) {
+ return await db.maybe('UPDATE codes_knowledge SET pinned = $3, updated_at = $4 WHERE id = $1 AND subject = $2 RETURNING *',
+ [String(id || ''), subject, !!pinned, now()]);
+ }
+
+ /** The owner's row, gone. */
+ async function remove({ subject, id } = {}) {
+ return await db.maybe('DELETE FROM codes_knowledge WHERE id = $1 AND subject = $2 RETURNING *', [String(id || ''), subject]);
+ }
+
+ /** Share the owner's row with one of their teams. The route has already checked the person is a member. */
+ async function share({ subject, id, teamId } = {}) {
+ return await db.maybe(`UPDATE codes_knowledge SET visibility = 'team', team_id = $3, updated_at = $4
+ WHERE id = $1 AND subject = $2 RETURNING *`, [String(id || ''), subject, String(teamId || ''), now()]);
+ }
+
+ /** The owner's row, private again. */
+ async function unshare({ subject, id } = {}) {
+ return await db.maybe(`UPDATE codes_knowledge SET visibility = 'private', team_id = NULL, updated_at = $3
+ WHERE id = $1 AND subject = $2 RETURNING *`, [String(id || ''), subject, now()]);
+ }
+
+ /**
+ * What one finished run taught, captured idempotently: the agent's Learned: and Fact: lines, a failure with its
+ * reason, the command that failed, and the commands the repository's runs use. Never the whole transcript.
+ */
+ async function captureTask(row) {
+ if (!row || !row.id || !row.subject) return [];
+ const result = row.result && typeof row.result === 'object' ? row.result : {};
+ const logLines = Array.isArray(result.log) ? result.log : [];
+ const summary = String(result.summary || '');
+ const texts = [summary, ...logLines.filter((l) => l && (l.t === 'text' || l.t === 'done')).map((l) => String(l.text || ''))];
+ const agent = (Array.isArray(result.agents) && result.agents[0]) || row.agent || null;
+ const repo = repoOf(row.repo);
+ const day = new Date(now()).toISOString().slice(0, 10);
+ const entries = [];
+
+ for (const text of texts) {
+ for (const line of learnedLines(text)) entries.push({ kind: 'lesson', text: line });
+ for (const line of factLines(text)) entries.push({ kind: 'fact', text: line });
+ }
+
+ const failed = result.ok === false || (row.state && row.state !== 'succeeded');
+ if (failed) {
+ const why = clean(String(row.error || result.error || summary || 'no error was reported').split(/\r?\n/)[0], 320);
+ entries.push({ kind: 'lesson', text: `${repo || 'This workspace'}: ${agent || 'an agent'} did not finish — ${why}` });
+ // The command that failed: the tool result keeps the id of the tool call that ran it.
+ const commands = new Map();
+ for (const line of logLines) {
+ if (line && line.t === 'tool' && line.id && line.input && line.input.command) commands.set(String(line.id), String(line.input.command));
+ }
+ const bad = [...logLines].reverse().find((l) => l && l.t === 'tool_result' && l.ok === false && String(l.output || '').trim());
+ if (bad) {
+ const command = commands.get(String(bad.id || ''));
+ const output = clean(String(bad.output || '').split(/\r?\n/).slice(0, 3).join(' '), 240);
+ entries.push({ kind: 'lesson', text: `${repo || 'This repository'}: \`${command || 'a command'}\` failed — ${output}` });
+ }
+ }
+
+ // The commands this repository's runs use, as facts: tests, builds and installs, at most two.
+ const seenCommands = new Set();
+ for (const line of logLines) {
+ const command = line && line.t === 'tool' && line.input && line.input.command ? String(line.input.command).trim() : '';
+ if (!command || !COMMAND.test(command) || seenCommands.has(command)) continue;
+ seenCommands.add(command);
+ entries.push({ kind: 'fact', text: `${repo || 'This workspace'}: \`${clean(command, 160)}\` is a command its runs use (ran by ${agent || 'an agent'} on ${day}).` });
+ if (seenCommands.size >= 2) break;
+ }
+
+ const out = [];
+ for (const entry of entries.slice(0, MAX_CAPTURE)) {
+ try {
+ const saved = await remember({
+ subject: row.subject, kind: entry.kind, text: entry.text, repo, paths: pathsIn(entry.text),
+ sourceTask: row.id, sourceJob: row.job_id || null, sourceAgent: agent,
+ });
+ out.push(saved.row);
+ } catch (e) {
+ if (!e || e.code !== 'knowledge.empty') log.warn(`[Codes] knowledge capture for ${row.id}: ${String((e && e.message) || e).slice(0, 160)}`);
+ }
+ }
+ return out;
+ }
+
+ /** Rank the rows a next run should see: pinned first, then this repository, then the words it shares with the brief. */
+ function rank(rows, { repo, prompt } = {}) {
+ const words = new Set(String(prompt || '').toLowerCase().match(/[a-z0-9_./-]{4,}/g) || []);
+ const at = now();
+ return (Array.isArray(rows) ? rows : [])
+ .map((row) => {
+ let score = 0;
+ if (row.pinned) score += 10;
+ if (repo && row.repo === repo) score += 4;
+ const text = String(row.text || '').toLowerCase();
+ for (const w of words) if (text.includes(w)) score += 1;
+ const days = Math.max(0, (at - Number(row.last_seen_at || 0)) / 86400000);
+ score += Math.max(0, 3 - days / 7);
+ return { row, score };
+ })
+ .filter((x) => x.score > 0 && (x.row.pinned || (repo && x.row.repo === repo) || x.score > 3))
+ .sort((a, b) => b.score - a.score || Number(b.row.last_seen_at) - Number(a.row.last_seen_at) || String(a.row.id).localeCompare(String(b.row.id)))
+ .map((x) => x.row);
+ }
+
+ /**
+ * The rows the next run's prompt gets. `projectId` is the run's own project: its team-shared rows are read without
+ * asking Network again (the run was started in that project, so the person belongs to it).
+ */
+ async function recall({ subject, projectId = null, repo = null, prompt = '', limit = 6 } = {}) {
+ const teams = projectId && String(projectId).startsWith('prj_') ? [projectId] : [];
+ const rows = await list({ subject, teamIds: teams, limit: 200 });
+ const picked = rank(rows, { repo: repoOf(repo), prompt }).slice(0, Math.max(1, Math.min(8, limit)));
+ return { rows: picked, text: block(picked, { limit: picked.length }) };
+ }
+
+ return {
+ projectsOf, teamIdsOf, list, get, remember, edit, pin, remove, share, unshare, captureTask, recall, rank,
+ block, present: (row, opts) => present(row, opts), scrub, repoOf, learnedLines, factLines, KINDS,
+ };
+}
+
+module.exports = {
+ createKnowledge, block, scrub, repoOf, learnedLines, factLines, dedupeKey, pathsIn, present, KINDS, MAX_TEXT,
+};
@@ -147,7 +147,7 @@ function usageOf(result) {
@@ -147,7 +147,7 @@ function usageOf(result) {
};
};
}
}
-function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null }) {
+function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null }) {
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@ -543,6 +543,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -543,6 +543,18 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
else refusal = attemptStore.whyNoAgent(f);
else refusal = attemptStore.whyNoAgent(f);
}
}
const runArgs = { ...args, agent, model };
const runArgs = { ...args, agent, model };
+ // Know K5: the lessons and facts earlier runs learned, in the prompt this run's harness gets. A store that
+ // cannot be read is skipped, never an error: a run must not fail because the memory is unavailable. The
+ // project the run belongs to also scopes the team rows it may read.
+ if (knowledge && runArgs.repo && runArgs.repo !== 'bench://') {
+ try {
+ const recalled = await knowledge.recall({ subject, projectId: project, repo: runArgs.repo, prompt: runArgs.prompt });
+ const text = knowledge.block(recalled.rows);
+ if (text) runArgs.knowledge = text;
+ } catch (e) {
+ log.warn(`[Codes] task ${id}: knowledge could not be recalled: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ }
const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
const actorRefValue = actorRef && typeof actorRef === 'object' && !Array.isArray(actorRef) ? JSON.stringify(actorRef) : null;
// Placement A1: the task's kind, its harness, and why the placer put it there. The person's rules, Bench priors
// Placement A1: the task's kind, its harness, and why the placer put it there. The person's rules, Bench priors
// and learned counts are read once here; the placement itself is pure. A task whose task kind is review is
// and learned counts are read once here; the placement itself is pure. A task whose task kind is review is
@@ -707,6 +719,14 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -707,6 +719,14 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const attempts = await attemptStore.list(row.id);
const attempts = await attemptStore.list(row.id);
const ending = attempts[attempts.length - 1];
const ending = attempts[attempts.length - 1];
if (!ending || !ENDED.has(ending.state)) return row;
if (!ending || !ENDED.has(ending.state)) return row;
+ // Know K5: what this attempt taught (its Learned: lines, a failure, the command that failed, the commands the
+ // repository uses) is stored before the next attempt is planned, so the next run can recall it. The capture is
+ // idempotent per (subject, kind, text), so a redelivered ending adds no second row.
+ if (knowledge) {
+ try { await knowledge.captureTask(row); } catch (e) {
+ log.warn(`[Codes] task ${row.id}: knowledge could not be captured: ${String((e && e.message) || e).slice(0, 200)}`);
+ }
+ }
// The proof this attempt's machine collected: the items are kept, and each one is an event on the stream.
// The proof this attempt's machine collected: the items are kept, and each one is an event on the stream.
const collected = await recordProof(row, ending);
const collected = await recordProof(row, ending);
const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });
const plan = await attemptStore.decide({ task: row, attempts, ending, result: row.result, fleet: await fleetFor(row.subject) });
@@ -38,6 +38,8 @@ const BUDGETS = {
@@ -38,6 +38,8 @@ const BUDGETS = {
// The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's
// The Home picker's repository search asks GitHub, which allows an address few searches a minute. The person's
// own repositories come first and cost nothing; this bounds what they may spend on the rest.
// own repositories come first and cost nothing; this bounds what they may spend on the rest.
'codes.repo.search': { minute: 6, hour: 60 },
'codes.repo.search': { minute: 6, hour: 60 },
+ // An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.
+ 'codes.knowledge.write': { minute: 30, hour: 600 },
};
};
/**
/**
@@ -845,6 +845,9 @@ ${conductorSection}
@@ -845,6 +845,9 @@ ${conductorSection}
// module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).
// module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).
r.use(require('./memory').createMemoryRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));
r.use(require('./memory').createMemoryRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));
+ // The Knowledge page (Know K5): the lessons and facts the person's own runs taught, with the run each came from.
+ r.use(require('./knowledge').createKnowledgeRoutes(ctx, { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note }));
+
// The guided setup (/setup): machine, agents, keys, accounts and choices, each step read live. Its home-page card
// The guided setup (/setup): machine, agents, keys, accounts and choices, each step read live. Its home-page card
// reads the database only, through ctx.setupProgress (the chat app's home asks it).
// reads the database only, through ctx.setupProgress (the chat app's home asks it).
const setupRoutes = require('./setup').createSetupRoutes(ctx, {
const setupRoutes = require('./setup').createSetupRoutes(ctx, {
@@ -192,6 +192,7 @@ function createChatRoutes(ctx) {
@@ -192,6 +192,7 @@ function createChatRoutes(ctx) {
place('Machines', '/machines', 'machines'),
place('Machines', '/machines', 'machines'),
place('OpenVibe Git', '/git', 'repo'),
place('OpenVibe Git', '/git', 'repo'),
place('Memory', '/memory', 'memory'),
place('Memory', '/memory', 'memory'),
+ place('Knowledge', '/knowledge', 'proof'),
place('Usage', '/usage', 'usage'),
place('Usage', '/usage', 'usage'),
...(waitingIds.size ? [{ href: '/approvals', label: 'Approvals', icon: 'hand', here: here === '/approvals', count: waitingIds.size }] : []),
...(waitingIds.size ? [{ href: '/approvals', label: 'Approvals', icon: 'hand', here: here === '/approvals', count: waitingIds.size }] : []),
place('Settings', '/settings', 'settings'),
place('Settings', '/settings', 'settings'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * The knowledge API for the person's own agents (Know K5): the `ov-know` MCP server reads and writes these rows with
+ * the person's own Network token. A cookie session works too (the page uses its own routes, not these).
+ *
+ * GET /api/v1/knowledge?q=&repo=&kind=&pinned=&limit= → { knowledge: […], count, teams_not_read? }
+ * POST /api/v1/knowledge { text, kind?, repo?, paths?, pinned?, team_id? } → { knowledge, created } (201)
+ *
+ * Every read is the person's own rows plus the rows their teams shared; the team list comes from Network with the
+ * caller's token, so no client can name a team it does not belong to. Every write is redacted before it is stored.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+
+function createKnowledgeApi({ config, store, knowledge, actorLimits, log = console }) {
+ const r = asyncRouter();
+ const json = express.json({ limit: '32kb' });
+ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const sameOrigin = (req) => {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ };
+
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+
+ r.get('/knowledge', actorLimits.reads('codes.knowledge.read'), async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to read your knowledge');
+ const token = req.token || (req.viewer && req.viewer.token) || null;
+ let teams = [];
+ let teamsNotRead = false;
+ try {
+ teams = await knowledge.teamIdsOf(token);
+ } catch {
+ teamsNotRead = true;
+ }
+ const kind = ['lesson', 'fact'].includes(String(req.query.kind || '')) ? String(req.query.kind) : null;
+ const rows = await knowledge.list({
+ subject: req.viewer.subject,
+ teamIds: teams,
+ q: req.query.q ? String(req.query.q).slice(0, 200) : null,
+ repo: req.query.repo ? knowledge.repoOf(String(req.query.repo).slice(0, 300)) : null,
+ kind,
+ pinned: req.query.pinned === '1' || req.query.pinned === 'true',
+ limit: Math.max(1, Math.min(100, Number(req.query.limit) || 50)),
+ });
+ return res.json({
+ knowledge: rows.map((row) => knowledge.present(row, { subject: req.viewer.subject })),
+ count: rows.length,
+ ...(teamsNotRead ? { teams_not_read: true } : {}),
+ });
+ });
+
+ r.post('/knowledge', actorLimits.budget('codes.knowledge.write'), json, async (req, res) => {
+ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to add knowledge');
+ if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site posts are refused');
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const text = typeof b.text === 'string' ? b.text : '';
+ if (!text.trim()) return problem(req, res, 422, 'knowledge.text_required', 'text is required');
+ if (!knowledge.KINDS.includes(b.kind || 'lesson')) return problem(req, res, 422, 'knowledge.kind_invalid', `kind is one of ${knowledge.KINDS.join(', ')}`);
+ if (b.repo != null && (typeof b.repo !== 'string' || b.repo.length > 300)) return problem(req, res, 422, 'knowledge.repo_invalid', 'repo must be a string of at most 300 characters');
+ if (b.paths != null && (!Array.isArray(b.paths) || b.paths.length > 12 || b.paths.some((p) => typeof p !== 'string' || p.length > 300))) {
+ return problem(req, res, 422, 'knowledge.paths_invalid', 'paths is at most 12 strings of 300 characters');
+ }
+ let teamId = null;
+ if (b.team_id != null && b.team_id !== '') {
+ const teams = await knowledge.projectsOf(req.token || (req.viewer && req.viewer.token) || null);
+ if (!teams.some((t) => t.id === String(b.team_id))) return problem(req, res, 403, 'knowledge.team_forbidden', 'you are not a member of that team');
+ teamId = String(b.team_id);
+ }
+ try {
+ const out = await knowledge.remember({
+ subject: req.viewer.subject,
+ kind: b.kind || 'lesson',
+ text,
+ repo: b.repo || null,
+ paths: b.paths || [],
+ pinned: b.pinned === true,
+ teamId,
+ visibility: teamId ? 'team' : 'private',
+ sourceAgent: 'agent',
+ });
+ log.info(`[Codes] knowledge: ${req.viewer.subject} ${out.created ? 'added' : 'repeated'} ${out.row.id}${teamId ? ' (shared)' : ''}`);
+ return res.status(out.created ? 201 : 200).json({ knowledge: knowledge.present(out.row, { subject: req.viewer.subject }), created: out.created });
+ } catch (e) {
+ return problem(req, res, e.status || 422, e.code || 'knowledge.invalid', e.message);
+ }
+ });
+
+ return r;
+}
+
+module.exports = { createKnowledgeApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+'use strict';
+
+/**
+ * The Knowledge page (Know K5): what a person's own coding runs taught them, and what their team shared.
+ *
+ * The rows live in Codes' own table (server/domain/knowledge.js). Every row is visible, editable, pinnable and
+ * deletable here, with the run it came from; private by default, shareable with a team (a Network project the person
+ * belongs to). The page is server-rendered and complete without JavaScript: the edit and share panels are details
+ * elements, and every write is a same-origin form post that answers with a flash back to the tab it came from.
+ *
+ * GET /knowledge the page: all, lessons, facts, pinned, team
+ * POST /knowledge add a lesson or fact by hand
+ * POST /knowledge/:id/edit its text and kind
+ * POST /knowledge/:id/pin pin or unpin it
+ * POST /knowledge/:id/delete the row, gone
+ * POST /knowledge/:id/share share it with one of the person's teams
+ * POST /knowledge/:id/unshare private again
+ *
+ * Secrets are scrubbed before the row is stored. Nothing here logs the text.
+ */
+
+const express = require('express');
+const { esc } = require('../render/html');
+const { asyncRouter } = require('./router');
+
+const ID = /^kn_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TABS = [['all', 'All'], ['lessons', 'Lessons'], ['facts', 'Facts'], ['pinned', 'Pinned'], ['team', 'Team']];
+
+const dayOf = (value) => {
+ const ms = Date.parse(String(value || ''));
+ if (!Number.isFinite(ms)) return '';
+ const d = new Date(ms);
+ const now = new Date();
+ if (d.toDateString() === now.toDateString()) return 'today';
+ const sameYear = d.getFullYear() === now.getFullYear();
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + (sameYear ? '' : ', ' + d.getFullYear());
+};
+const agoOf = (value) => {
+ const ms = Date.parse(String(value || ''));
+ if (!Number.isFinite(ms)) return '';
+ const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins} min ago`;
+ if (mins < 1440) return `${Math.floor(mins / 60)} h ago`;
+ return `${Math.floor(mins / 1440)} d ago`;
+};
+
+function createKnowledgeRoutes(ctx, kit) {
+ const { page, wrap, header, pill, flash, sameOrigin, viewer, form, signIn, note } = kit;
+ const { knowledge } = ctx;
+ const router = asyncRouter();
+
+ const back = (req, res, fallback, ok, error) => {
+ const asked = String((req.body && req.body.tab) || req.query.tab || '');
+ const tab = TABS.some(([id]) => id === asked) ? asked : fallback;
+ const target = `/knowledge?tab=${encodeURIComponent(tab)}`;
+ if (error) return res.redirect(303, `${target}&error=${encodeURIComponent(String(error).slice(0, 180))}`);
+ return res.redirect(303, `${target}&ok=${encodeURIComponent(String(ok).slice(0, 140))}`);
+ };
+
+ const teamsOf = async (req, res) => {
+ try {
+ return await ctx.sso.asViewer(req, res, (token) => knowledge.projectsOf(token));
+ } catch {
+ return [];
+ }
+ };
+
+ async function rowsOf(req, res) {
+ let teams = [];
+ try {
+ teams = await ctx.sso.asViewer(req, res, (token) => knowledge.teamIdsOf(token));
+ } catch { /* private rows only */ }
+ const rows = await knowledge.list({ subject: req.viewer.subject, teamIds: teams, limit: 500 });
+ return { rows, teams };
+ }
+
+ const matches = (tab) => (row) => tab === 'all' ? true
+ : tab === 'lessons' ? row.kind === 'lesson'
+ : tab === 'facts' ? row.kind === 'fact'
+ : tab === 'pinned' ? !!row.pinned
+ : row.visibility === 'team';
+
+ function addForm(teams) {
+ return `<details class="c4-more kn-add"><summary>Add a lesson or a fact</summary><div class="c4-more-body">
+<form class="c4-form" method="post" action="/knowledge">
+<input type="hidden" name="tab" value="all">
+<label>What should the next run know?<textarea name="text" rows="3" maxlength="2000" required placeholder="The home test pins the sc-navbar markup (test/home.test.js)."></textarea></label>
+<div class="c4-form-row">
+<label>Kind<select name="kind"><option value="lesson">lesson</option><option value="fact">fact</option></select></label>
+<label>Repository <span class="c4-muted">(optional)</span><input name="repo" maxlength="300" placeholder="OpenVibers/OpenVibe.Codes"></label>
+</div>
+${teams.length ? `<label>Share with <select name="team_id"><option value="">Just me</option>${teams.map((t) => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></label>` : '<p class="c4-hint">Private to you. Share it with a team once you belong to one.</p>'}
+<button class="c4-button" type="submit">Remember this</button>
+</form></div></details>`;
+ }
+
+ function rowHtml(row, { mine, teams, tab }) {
+ const team = row.team_id ? teams.find((t) => t.id === row.team_id) : null;
+ // The repository is where a lesson applies, said with the source line rather than floating in the head.
+ const where = row.repo ? ` · <span class="kn-repo">${esc(String(row.repo).replace(/^(?:https?:\/\/)?(?:www\.)?github\.com\//i, ''))}</span>` : '';
+ const source = row.source_task
+ ? `<p class="kn-source">From <a href="/tasks/${esc(row.source_task)}">the run that learned it</a>${row.source_agent ? ` · ${esc(row.source_agent)}` : ''}${where}</p>`
+ : `<p class="kn-source">Added by you${where}</p>`;
+ const shared = row.visibility === 'team'
+ ? pill(`Shared${team ? ` with ${team.name}` : ''}`, 'ok')
+ : pill('Private');
+ const actions = mine ? `<div class="kn-actions">
+<form method="post" action="/knowledge/${esc(row.id)}/pin"><input type="hidden" name="tab" value="${esc(tab)}"><input type="hidden" name="pinned" value="${row.pinned ? '0' : '1'}"><button class="c4-button c4-button-light" type="submit">${row.pinned ? 'Unpin' : 'Pin'}</button></form>
+<details class="c4-more"><summary>Edit</summary><div class="c4-more-body">
+<form class="c4-form" method="post" action="/knowledge/${esc(row.id)}/edit">
+<input type="hidden" name="tab" value="${esc(tab)}">
+<label>Text<textarea name="text" rows="3" maxlength="2000" required>${esc(row.text)}</textarea></label>
+<label>Kind<select name="kind"><option value="lesson"${row.kind === 'lesson' ? ' selected' : ''}>Lesson</option><option value="fact"${row.kind === 'fact' ? ' selected' : ''}>Fact</option></select></label>
+<button class="c4-button" type="submit">Save</button>
+</form></div></details>
+${row.visibility === 'team' ? `<form method="post" action="/knowledge/${esc(row.id)}/unshare"><input type="hidden" name="tab" value="${esc(tab)}"><button class="c4-button c4-button-light" type="submit">Make private</button></form>`
+ : (teams.length ? `<details class="c4-more"><summary>Share</summary><div class="c4-more-body"><form class="c4-form" method="post" action="/knowledge/${esc(row.id)}/share"><input type="hidden" name="tab" value="${esc(tab)}"><label>Team<select name="team_id">${teams.map((t) => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></label><button class="c4-button" type="submit">Share with this team</button></form></div></details>`
+ : '<span class="c4-hint">No team to share with yet.</span>')}
+<details class="c4-more kn-delete"><summary>Delete</summary><div class="c4-more-body"><p class="kn-confirm">Delete this ${row.kind === 'fact' ? 'fact' : 'lesson'}? Runs stop recalling it, and it cannot be brought back.</p><form method="post" action="/knowledge/${esc(row.id)}/delete"><input type="hidden" name="tab" value="${esc(tab)}"><button class="c4-button c4-button-danger" type="submit">Delete it</button></form></div></details>
+</div>` : `<p class="kn-team-note">${row.visibility === 'team' ? 'Shared with your team.' : ''} Only ${mine ? 'you' : 'its owner'} can edit it.</p>`;
+ return `<article class="c4-card kn-row${row.pinned ? ' is-pinned' : ''}">
+<div class="kn-row-head">${pill(row.kind === 'fact' ? 'Fact' : 'Lesson')}${row.pinned ? pill('Pinned', 'pinned') : ''}${shared}</div>
+<p class="kn-text">${esc(row.text)}</p>
+<p class="kn-meta">${esc(dayOf(row.last_seen_at))}${row.seen > 1 ? ` · seen ${row.seen} times` : ''}${row.redacted ? ' · secrets removed' : ''}</p>
+${source}${actions}
+</article>`;
+ }
+
+ async function knowledgePage(req, res) {
+ const title = 'Knowledge';
+ const head = header(title, 'What your runs learned, ready for the next one. Private until you share it.');
+ if (!viewer(req)) return page(req, res, title, wrap(head + signIn(req)));
+ const asked = String(req.query.tab || '');
+ const tab = TABS.some(([id]) => id === asked) ? asked : 'all';
+ let rows = [];
+ let teams = [];
+ let problem = null;
+ try {
+ const out = await rowsOf(req, res);
+ rows = out.rows;
+ teams = await teamsOf(req, res);
+ } catch (e) {
+ problem = e;
+ }
+ const mine = rows.filter((r) => r.subject === req.viewer.subject);
+ const counts = {
+ all: rows.length,
+ lessons: rows.filter((r) => r.kind === 'lesson').length,
+ facts: rows.filter((r) => r.kind === 'fact').length,
+ pinned: rows.filter((r) => r.pinned).length,
+ team: rows.filter((r) => r.visibility === 'team').length,
+ };
+ const shown = rows.filter(matches(tab));
+ const tabs = `<nav class="c4-tabs" aria-label="Knowledge">${TABS.map(([id, label]) => {
+ const n = counts[id];
+ return `<a href="/knowledge?tab=${id}"${id === tab ? ' aria-current="page"' : ''}>${esc(`${label} (${n})`)}</a>`;
+ }).join('')}</nav>`;
+ const summary = `<div class="kn-summary"><div><strong>${mine.length}</strong><span>yours</span></div><div><strong>${counts.pinned}</strong><span>pinned</span></div><div><strong>${counts.team}</strong><span>shared</span></div></div>`;
+ const empty = `<section class="c4-card c4-empty"><h2>${tab === 'all' ? 'Nothing learned yet' : 'Nothing here yet'}</h2><p>Run a task and Codes stores what it learned: what failed and why, the repository's commands, its traps. Every row is yours to edit, pin or delete.</p><p class="c4-hint">The first run has nothing to recall; the second one starts with it.</p></section>`;
+ const content = `${head}${flash(req)}${problem ? note('Your knowledge could not be read right now. Try again in a moment.', 'bad') : ''}
+${summary}
+<p class="c4-hint">Private to you by default. Share a row with a team and its members can read it in their own runs; only you can edit or delete it.</p>
+${addForm(teams)}
+${tabs}
+<div class="kn-list">${shown.length ? shown.map((row) => rowHtml(row, { mine: row.subject === req.viewer.subject, teams, tab })).join('') : empty}</div>`;
+ return page(req, res, title, wrap(content));
+ }
+
+ router.get('/knowledge', knowledgePage);
+
+ router.post('/knowledge', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const b = req.body || {};
+ try {
+ const teams = await teamsOf(req, res);
+ const teamId = b.team_id ? String(b.team_id) : '';
+ if (teamId && !teams.some((t) => t.id === teamId)) throw Object.assign(new Error('you are not a member of that team'), { status: 403 });
+ const out = await knowledge.remember({
+ subject: req.viewer.subject, kind: b.kind, text: b.text, repo: b.repo || null,
+ teamId: teamId || null, visibility: teamId ? 'team' : 'private',
+ });
+ return back(req, res, 'all', out.created ? 'Remembered.' : 'That was already here; it now counts one more sighting.');
+ } catch (e) {
+ return back(req, res, 'all', '', e.message || 'That row could not be saved.');
+ }
+ });
+
+ router.post('/knowledge/:id/edit', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');
+ try {
+ const row = await knowledge.edit({ subject: req.viewer.subject, id: req.params.id, text: req.body.text, kind: req.body.kind });
+ if (!row) return back(req, res, 'all', '', 'There is no such row of yours.');
+ return back(req, res, 'all', 'Saved.');
+ } catch (e) {
+ return back(req, res, 'all', '', e.message || 'That row could not be saved.');
+ }
+ });
+
+ router.post('/knowledge/:id/pin', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');
+ const pinned = String((req.body && req.body.pinned) || '') === '1';
+ const row = await knowledge.pin({ subject: req.viewer.subject, id: req.params.id, pinned });
+ return back(req, res, String((req.body && req.body.tab) || 'all'), row ? (pinned ? 'Pinned.' : 'Unpinned.') : '');
+ });
+
+ router.post('/knowledge/:id/delete', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');
+ const row = await knowledge.remove({ subject: req.viewer.subject, id: req.params.id });
+ return back(req, res, 'all', row ? 'Deleted.' : '', row ? '' : 'There is no such row of yours.');
+ });
+
+ router.post('/knowledge/:id/share', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');
+ const teamId = String((req.body && req.body.team_id) || '');
+ try {
+ const teams = await teamsOf(req, res);
+ if (!teams.some((t) => t.id === teamId)) throw new Error('you are not a member of that team');
+ const row = await knowledge.share({ subject: req.viewer.subject, id: req.params.id, teamId });
+ return back(req, res, 'all', row ? 'Shared with your team.' : '', row ? '' : 'There is no such row of yours.');
+ } catch (e) {
+ return back(req, res, 'all', '', e.message || 'That row could not be shared.');
+ }
+ });
+
+ router.post('/knowledge/:id/unshare', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fknowledge');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!ID.test(String(req.params.id || ''))) return back(req, res, 'all', '', 'There is no such row.');
+ const row = await knowledge.unshare({ subject: req.viewer.subject, id: req.params.id });
+ return back(req, res, 'all', row ? 'Private again.' : '', row ? '' : 'There is no such row of yours.');
+ });
+
+ return router;
+}
+
+module.exports = { createKnowledgeRoutes, TABS, dayOf, agoOf };
@@ -19,8 +19,9 @@ const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/
@@ -19,8 +19,9 @@ const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the person's
// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*), the person's
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
-// with its approvals inbox (Codes C9b) and the fleet their machines report (Codes C9c).
-const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet)(\/|$)/;
+// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
+// their own agents read and add (Know K5).
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
@@ -42,6 +42,7 @@ const WORKSPACE_NAV = [
@@ -42,6 +42,7 @@ const WORKSPACE_NAV = [
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
{ label: 'Machines', href: '/machines', icon: 'fa-display' },
{ label: 'Machines', href: '/machines', icon: 'fa-display' },
{ label: 'Memory', href: '/memory', icon: 'fa-brain' },
{ label: 'Memory', href: '/memory', icon: 'fa-brain' },
+ { label: 'Knowledge', href: '/knowledge', icon: 'fa-book' },
{ label: 'Usage', href: '/usage', icon: 'fa-chart-line' },
{ label: 'Usage', href: '/usage', icon: 'fa-chart-line' },
{ label: 'Settings', href: '/settings', icon: 'fa-gear' },
{ label: 'Settings', href: '/settings', icon: 'fa-gear' },
];
];
@@ -72,7 +73,7 @@ function renderPage(o) {
@@ -72,7 +73,7 @@ function renderPage(o) {
const pathNow = o.path || '/';
const pathNow = o.path || '/';
// The workspace pages share the compact Frame: no second app bar under the Frame's own (a query must not change it).
// The workspace pages share the compact Frame: no second app bar under the Frame's own (a query must not change it).
const pathOnly = pathNow.split('?')[0];
const pathOnly = pathNow.split('?')[0];
- const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|usage|settings)(\/|$)/.test(pathOnly);
+ const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|knowledge|usage|settings)(\/|$)/.test(pathOnly);
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with
// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with
@@ -58,6 +58,8 @@ async function startNetworkStub() {
@@ -58,6 +58,8 @@ async function startNetworkStub() {
await db.exec("INSERT INTO codes_notices (task_id, kind) VALUES ($1, 'done')", [taskId]);
await db.exec("INSERT INTO codes_notices (task_id, kind) VALUES ($1, 'done')", [taskId]);
await db.exec(`INSERT INTO codes_machine_fleet (subject, machine, name, agent, installed, signed_in, windows, online, reported_at)
await db.exec(`INSERT INTO codes_machine_fleet (subject, machine, name, agent, installed, signed_in, windows, online, reported_at)
VALUES ($1, 'dev_laptop', 'laptop', 'codex', true, true, '[]'::jsonb, true, now())`, [subject]);
VALUES ($1, 'dev_laptop', 'laptop', 'codex', true, true, '[]'::jsonb, true, now())`, [subject]);
+ await db.exec(`INSERT INTO codes_knowledge (id, subject, kind, text, repo, dedupe_key, created_at, updated_at, last_seen_at)
+ VALUES ($1, $2, 'lesson', $3, 'github.com/a/b', $4, 1, 1, 1)`, [`kn_${ids.ulid()}`, subject, `${subject} learned this (README.md)`, ids.ulid()]);
}
}
const deliver = async (event, { secret = SECRET } = {}) => {
const deliver = async (event, { secret = SECRET } = {}) => {
const body = JSON.stringify({ event, seq: 1 });
const body = JSON.stringify({ event, seq: 1 });
@@ -111,6 +113,10 @@ async function startNetworkStub() {
@@ -111,6 +113,10 @@ async function startNetworkStub() {
assert.ok(fleet, 'fleet.json is in the export');
assert.ok(fleet, 'fleet.json is in the export');
assert.strictEqual(fleet.content.length, 1, 'Ada’s fleet only');
assert.strictEqual(fleet.content.length, 1, 'Ada’s fleet only');
assert.strictEqual(fleet.content[0].agent, 'codex');
assert.strictEqual(fleet.content[0].agent, 'codex');
+ const kn = part.body.files.find((x) => x.name === 'knowledge.json');
+ assert.ok(kn, 'knowledge.json is in the export');
+ assert.strictEqual(kn.content.length, 1, 'Ada’s knowledge only');
+ assert.match(kn.content[0].text, /learned this \(README\.md\)/);
});
});
await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {
await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {
@@ -127,6 +133,7 @@ async function startNetworkStub() {
@@ -127,6 +133,7 @@ async function startNetworkStub() {
['codes_attempts', 'task_id', [boTask]], ['codes_task_proof', 'task_id', [boTask]], ['codes_events_inbox', 'task_id', [boTask]],
['codes_attempts', 'task_id', [boTask]], ['codes_task_proof', 'task_id', [boTask]], ['codes_events_inbox', 'task_id', [boTask]],
['codes_notices', 'task_id', [boTask]], ['codes_machine_fleet', 'subject', [bo]],
['codes_notices', 'task_id', [boTask]], ['codes_machine_fleet', 'subject', [bo]],
['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],
['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],
+ ['codes_knowledge', 'subject', [bo]],
]) {
]) {
const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);
const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);
assert.deepStrictEqual(left, who, `${table}: only Bo’s row is left`);
assert.deepStrictEqual(left, who, `${table}: only Bo’s row is left`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,255 @@
@@ -0,0 +1,255 @@
+'use strict';
+
+/**
+ * Know K5: workspace knowledge on Codes.
+ *
+ * a run's ending is captured (its Learned: lines, a failure, the command that failed, the commands it uses), with
+ * secrets removed; the next run's job carries it in its prompt; every row is visible, editable, pinnable and
+ * deletable on /knowledge with its source run; a row is private until its owner shares it with a team, and only
+ * that team's members (not everyone) can read it; the API the ov-know MCP speaks answers the same rows.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const { boot, check, done } = require('./helpers/boot');
+const { parseArgs, agentPrompt, runJob } = require('../harness/job');
+const secrets = require('./helpers/fakesecrets');
+
+const REPO = 'https://github.com/OpenVibers/OpenVibe.Codes';
+const PROJECT = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
+const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** A small repository to clone, as a file:// URL. */
+function repository() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-kn-src-'));
+ const sh = (...args) => execFileSync('git', args, {
+ cwd: dir, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' },
+ });
+ sh('init', '-q', '-b', 'main');
+ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n');
+ sh('add', '-A');
+ sh('commit', '-q', '-m', 'first');
+ return { dir, url: `file://${dir}` };
+}
+
+/** A stand-in harness that records the prompt the job actually hands it. */
+function fakeHarness() {
+ const calls = [];
+ return {
+ calls,
+ run(opts) {
+ calls.push(opts);
+ return (async function* run() {
+ yield { type: 'system', subtype: 'init', agent: 'claude-code', model: 'test' };
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Done.' }] } };
+ yield { type: 'result', is_error: false, result: 'Done.\n\nLearned:\n- It worked (README.md).', total_cost_usd: 0.001 };
+ })();
+ },
+ };
+}
+
+(async () => {
+ const calls = [];
+ const jobs = new Map();
+ let counter = 0;
+ const run = {
+ enabled: true,
+ async submit(o) {
+ counter++;
+ const id = `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2K${String(counter).padStart(2, '0')}`;
+ jobs.set(id, { id, state: 'queued', args: o.args });
+ calls.push(o);
+ return { id, state: 'queued' };
+ },
+ async read(jobId) { return jobs.get(jobId) || { id: jobId, state: 'queued' }; },
+ async cancel(jobId) { const job = jobs.get(jobId); if (job) job.state = 'cancelled'; return job; },
+ async streamUrl() { throw new Error('not used'); },
+ };
+ const t = await boot({ run });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const carol = t.network.addUser('carol');
+ t.network.state.projects.set(PROJECT, {
+ id: PROJECT, name: 'The team', owner: ada.subject, environment_policy: 'sandbox', allowance: [],
+ created_at: new Date().toISOString(), archived_at: null,
+ });
+ t.network.state.members.set(PROJECT, new Map([[ada.subject, 'owner'], [bo.subject, 'developer']]));
+
+ const startTask = async (prompt) => await t.ctx.tasks.start({ subject: ada.subject, project: PROJECT, args: parseArgs(JSON.stringify({ repo: REPO, prompt, agent: 'claude-code' })) });
+ const rowOf = async (id) => await db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);
+ const end = async (id, state, result, error = null) => {
+ const row = await rowOf(id);
+ const updated = await t.ctx.tasks.applyEvent(row, { state, job: { id: row.job_id, state, result, error: null }, error });
+ await t.ctx.tasks.afterEnd(updated.id);
+ return await rowOf(id);
+ };
+ const knowledgeOf = async (subject) => await db.many('SELECT * FROM codes_knowledge WHERE subject = $1 ORDER BY created_at ASC', [subject]);
+
+ try {
+ let firstId = null;
+ let captured = null;
+
+ await check('a finished run is captured: its Learned: lines, the failure, the failed command and the commands it used', async () => {
+ firstId = (await startTask('Fix the home test after the pin bump.')).id;
+ assert.match(firstId, TASK_ID);
+ const provider = secrets.providerKey();
+ const password = secrets.password();
+ const result = {
+ ok: true,
+ summary: `I finished.\n\nLearned:\n- The home test pins the sc-navbar markup (test/home.test.js).\n- The token ${provider} must not be stored; ${password}.`,
+ agents: ['codex'],
+ log: [
+ { t: 'tool', id: 't1', name: 'Bash', input: { command: 'npm test' } },
+ { t: 'tool_result', id: 't1', ok: true, output: '2 tests passed' },
+ { t: 'text', text: 'I finished.\n\nLearned:\n- The home test pins the sc-navbar markup (test/home.test.js).' },
+ ],
+ };
+ const ended = await end(firstId, 'succeeded', result);
+ assert.strictEqual(ended.state, 'succeeded');
+ // The failure path is the same capture, from an attempt that did not finish; call it with that ending.
+ await t.ctx.knowledge.captureTask({
+ ...ended, state: 'failed', error: 'the tests failed',
+ result: {
+ ok: false, summary: 'I could not finish.', agents: ['codex'],
+ log: [
+ { t: 'tool', id: 't1', name: 'Bash', input: { command: 'npm test' } },
+ { t: 'tool_result', id: 't1', ok: false, output: 'Error: 2 tests failed\n at home.test.js:42' },
+ ],
+ },
+ });
+ captured = await knowledgeOf(ada.subject);
+ assert.ok(captured.length >= 4, `${captured.length} rows captured`);
+ const learned = captured.find((r) => /sc-navbar markup/.test(r.text));
+ assert.ok(learned, 'the Learned: line is stored');
+ assert.strictEqual(learned.source_task, firstId, 'the row names the run it came from');
+ assert.strictEqual(learned.kind, 'lesson');
+ const heldSecret = captured.find((r) => /\[redacted\]/.test(r.text));
+ assert.ok(heldSecret, 'the row that held a key is stored with the key replaced');
+ assert.ok(heldSecret.redacted, 'and is marked redacted');
+ const all = captured.map((r) => r.text).join('\n');
+ assert.ok(!all.includes(provider) && !all.includes(secrets.passwordValue()), 'the provider key and password are gone');
+ assert.match(all, /\[redacted\]/);
+ assert.ok(captured.some((r) => r.kind === 'fact' && /npm test/.test(r.text)), 'the command the repository uses is a fact');
+ assert.ok(captured.some((r) => /npm test` failed/.test(r.text)), 'the command that failed is a lesson');
+ assert.ok(captured.every((r) => r.visibility === 'private'), 'captured rows are private by default');
+ });
+
+ await check('the next run’s job carries them, and the harness prompt gets them before the brief', async () => {
+ const second = await startTask('Fix the pin bump bundle growth in package.json.');
+ assert.match(second.id, TASK_ID);
+ const submitted = calls.at(-1);
+ assert.ok(submitted.args.knowledge, 'the job input carries the knowledge block');
+ assert.match(submitted.args.knowledge, /sc-navbar markup/);
+ const parsed = parseArgs(JSON.stringify(submitted.args));
+ assert.match(parsed.knowledge, /OpenVibe\.Codes Knowledge/);
+ const prompt = agentPrompt(parsed);
+ assert.ok(prompt.startsWith(parsed.knowledge), 'the knowledge is the prompt’s first section');
+ assert.ok(prompt.includes('Fix the pin bump bundle growth'), 'the person’s brief follows it');
+ assert.ok(prompt.includes('End with a `Learned:` block'), 'and the next run is asked to teach one back');
+ // End it so the test leaves no queued task behind.
+ await end(second.id, 'succeeded', { ok: true, summary: 'Done.', agents: ['claude-code'], log: [] });
+ });
+
+ await check('a job’s prompt is what the harness receives, for every adapter', async () => {
+ const src = repository();
+ const harness = fakeHarness();
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-kn-tmp-'));
+ try {
+ const doc = await runJob({
+ input: JSON.stringify({ repo: src.url, prompt: 'Add a line', knowledge: '## What earlier runs learned\n\n- Use npm test (package.json).\n\n---\n' }),
+ harness, emit: () => {}, writeResult: () => {}, env: { ...process.env, OPENVIBE_CODES_ALLOW_FILE_REPO: '1' }, tmp,
+ });
+ assert.strictEqual(doc.ok, true, JSON.stringify(doc));
+ assert.ok(harness.calls[0].prompt.startsWith('## What earlier runs learned'), 'the prefix reaches the adapter');
+ assert.ok(harness.calls[0].prompt.includes('Add a line'));
+ assert.ok(harness.calls[0].prompt.includes('Before you finish (OpenVibe.Codes Knowledge)'));
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ });
+
+ await check('the page shows every row with its source run, and its edit, pin, share and delete forms', async () => {
+ const page = await t.get('/knowledge', { as: ada });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ assert.ok(page.text.includes('Knowledge'), 'the page title');
+ assert.ok(page.text.includes('The home test pins the sc-navbar markup'), 'the captured text');
+ assert.ok(page.text.includes(`/tasks/${firstId}`), 'the source run links to the task');
+ assert.ok(page.text.includes('Add a lesson or a fact'), 'the add form');
+ assert.ok(page.text.includes('Pin'), 'pin');
+ assert.ok(page.text.includes('>Edit<'), 'edit');
+ assert.ok(page.text.includes('>Delete<') && page.text.includes('>Delete it<'), 'delete');
+ assert.ok(page.text.includes('Share'), 'share');
+ assert.ok(page.text.includes('The team'), 'the person’s team is offered');
+ });
+
+ await check('a row can be edited, pinned, and deleted by its owner', async () => {
+ const row = (await knowledgeOf(ada.subject)).find((r) => /sc-navbar markup/.test(r.text));
+ const edited = await t.get(`/knowledge/${row.id}/edit`, { as: ada, form: { tab: 'all', text: 'The home test pins the sc-navbar markup (test/home.test.js); update it with a pin.', kind: 'fact' } });
+ assert.strictEqual(edited.status, 303);
+ const after = await db.one('SELECT * FROM codes_knowledge WHERE id = $1', [row.id]);
+ assert.strictEqual(after.kind, 'fact');
+ assert.match(after.text, /update it with a pin/);
+ const pinned = await t.get(`/knowledge/${row.id}/pin`, { as: ada, form: { tab: 'all', pinned: '1' } });
+ assert.strictEqual(pinned.status, 303);
+ assert.strictEqual((await db.one('SELECT pinned FROM codes_knowledge WHERE id = $1', [row.id])).pinned, true);
+ const deleted = await t.get(`/knowledge/${row.id}/delete`, { as: ada, form: { tab: 'all' } });
+ assert.strictEqual(deleted.status, 303);
+ assert.strictEqual(await db.maybe('SELECT 1 FROM codes_knowledge WHERE id = $1', [row.id]), null);
+ });
+
+ await check('a row is shared with a team: its members read it, a non-member does not, and only the owner edits it', async () => {
+ const shared = await t.ctx.knowledge.remember({
+ subject: ada.subject, kind: 'lesson', text: 'Team only: the deploy waits for the release tag (release.json).', repo: 'OpenVibers/OpenVibe.Codes',
+ });
+ const share = await t.get(`/knowledge/${shared.row.id}/share`, { as: ada, form: { tab: 'all', team_id: PROJECT } });
+ assert.strictEqual(share.status, 303);
+ const row = await db.one('SELECT * FROM codes_knowledge WHERE id = $1', [shared.row.id]);
+ assert.strictEqual(row.visibility, 'team');
+ assert.strictEqual(row.team_id, PROJECT);
+
+ const boPage = await t.get('/knowledge', { as: bo });
+ assert.ok(boPage.text.includes('Team only: the deploy waits'), 'a team member reads it');
+ assert.ok(boPage.text.includes('Shared with The team'), 'and it says why');
+ const carolPage = await t.get('/knowledge', { as: carol });
+ assert.ok(!carolPage.text.includes('Team only: the deploy waits'), 'a non-member does not');
+ const boEdit = await t.get(`/knowledge/${row.id}/edit`, { as: bo, form: { tab: 'all', text: 'hijacked', kind: 'lesson' } });
+ assert.strictEqual(boEdit.status, 303);
+ assert.match((await db.one('SELECT text FROM codes_knowledge WHERE id = $1', [row.id])).text, /deploy waits/, 'a teammate cannot edit it');
+ const boDelete = await t.get(`/knowledge/${row.id}/delete`, { as: bo, form: { tab: 'all' } });
+ assert.strictEqual(boDelete.status, 303);
+ assert.ok(await db.maybe('SELECT 1 FROM codes_knowledge WHERE id = $1', [row.id]), 'and cannot delete it');
+ });
+
+ await check('the API the ov-know MCP speaks reads the rows, adds a redacted row, and refuses another person', async () => {
+ const read = await t.get('/api/v1/knowledge', { bearer: t.network.userToken(ada) });
+ assert.strictEqual(read.status, 200, read.text.slice(0, 200));
+ assert.ok(read.json().knowledge.length >= 2, 'the person’s rows');
+ assert.ok(read.json().knowledge.every((r) => typeof r.mine === 'boolean'));
+ const secret = secrets.providerKey();
+ const added = await t.get('/api/v1/knowledge', {
+ method: 'POST', bearer: t.network.userToken(ada),
+ json: { text: `A fact with a key ${secret} (src/app.js).`, kind: 'fact', repo: 'OpenVibers/OpenVibe.Codes' },
+ });
+ assert.strictEqual(added.status, 201, added.text.slice(0, 200));
+ assert.ok(!added.json().knowledge.text.includes(secret), 'the answer is redacted');
+ assert.match(added.json().knowledge.text, /\[redacted\]/);
+ const boRead = await t.get('/api/v1/knowledge', { bearer: t.network.userToken(bo) });
+ assert.ok(boRead.json().knowledge.some((r) => /deploy waits/.test(r.text)), 'a teammate reads the shared row');
+ const anon = await t.get('/api/v1/knowledge');
+ assert.strictEqual(anon.status, 401);
+ });
+
+ await check('the person’s knowledge is exported with the account and erased with it', async () => {
+ const { TABLES } = require('../server/account-data');
+ assert.ok(TABLES.some((x) => x.table === 'codes_knowledge' && x.subject === 'subject'), 'the table is covered');
+ const before = Number(await db.value('SELECT count(*) FROM codes_knowledge WHERE subject = $1', [ada.subject]));
+ assert.ok(before > 0);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -19,8 +19,8 @@ const BUDGETS = {
@@ -19,8 +19,8 @@ const BUDGETS = {
jsRawKB: 357.8, // 357.8 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 355.8 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 354.5 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 353.2 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 349.9 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 348.7 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 345.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 342.2 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 341.4 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 340.6 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 357.8, // 357.8 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 355.8 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 354.5 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 353.2 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 349.9 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 348.7 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 345.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 342.2 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 341.4 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 340.6 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 330.9 after C9b: the conductor's message box with its images and its live turn (2.6 KB in js/c4.js); 326.2 after C4b: the home page loads the workspace script for its repository search (10.9 KB of its own); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 85.9, // 85.9 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 85.6 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 85.3 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 84.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 85.9, // 85.9 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 85.6 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 85.3 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 84.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 82.9 after C9b's conductor script; 80.6 after C4b's repository search on the home page; 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
cssFiles: 3, // C4 adds its responsive workspace stylesheet alongside codes.css and run.css.
- cssRawKB: 75, // 74.7 after the conductor failover: the fallback rows in Settings, the usage card, a switched turn (public/css/c4.css, 1.3 KB, 2026-11-03); 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
- cssBrotliKB: 15.5, // 15.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
+ cssRawKB: 78, // 77.1 with Know K5: the Knowledge page's rows, add form, source line and actions (public/css/c4.css, 1.9 KB, 2026-11-06); 74.7 after the conductor failover: the fallback rows in Settings, the usage card, a switched turn (public/css/c4.css, 1.3 KB, 2026-11-03); 73.2 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 72.2 with 2026-11-01: phone table cards (2026-11-01); 70.7 after the front door: a visitor's example conversation, its hand-over line and result row (public/css/c4.css, 1.4 KB, 2026-10-31); 69.3 after the Memory M2b review: the quiet Remove link, the phone sources fold, the in-place note menu and the danger buttons (public/css/c4.css, 2.2 KB); 67.1 after Memory M2b: the Memory page's sources, notes, note menus, conflicts and tabs (public/css/c4.css, 2.4 KB); 63.0 after C9c: the fleet's rows and its meters on the Agents page (public/css/c4.css, 1.2 KB); 62.4 after C9b: the conductor's turns, approvals inbox and composer (public/css/c4.css); 58.4 after C4b: the proof rows, the fold-out panels, the shots, the forms that remember choices and their light/dark styles; 46 KB after adding the C4 phone-first workspace, cards, task timeline and light/dark styles.
+ cssBrotliKB: 16.0, // 15.7 with Know K5's Knowledge page; 15.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 14.2 after the Memory M2b review (the same quiet link, sources fold, in-place menu and danger buttons); 13.0 after C9b's conductor styles; 12.1 after C4b's rows, panels and forms; 9.9 KB after adding the C4 workspace styles.
externalFiles: 2, // 0
externalFiles: 2, // 0
};
};
@@ -56,6 +56,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -56,6 +56,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
+ 'server/domain/knowledge.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, to read the teams a knowledge row may be shared with; the path is fixed and no URL from a person is fetched',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',
'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',
'server/domain/fleet.js': 'OpenVibe.Bot (CODES_BOT_INTERNAL_URL, configured) /api/v1/machines with the person\'s own session token: their machines and the fleet their Nodes reported; the path is fixed, never a URL from a person',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',