openvibers/openvibe.codes Commits

Know K5: workspace knowledge — what a run learned, recalled by the next one (#286)

OP OpenVibers committed 7 days ago · e4aa478ec4 · 19 files, +1183 −10

* Know K5: workspace knowledge — capture what a run learned, recall it in the next one

* Knowledge rows: Delete is a quiet fold that asks first, the repository sits on the source line, kinds are capitalised

Five rows showed five filled red Delete buttons that deleted on one tap; the repository floated alone at the right of
the chips. Edit reads like the other actions.

Changes

ddiff --git a/README.md b/README.md
iindex b5579d2..fd7f7ed 100644
---- a/README.md
++++ b/README.md
@@@ -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`.
ddiff --git a/harness/job.js b/harness/job.js
iindex 7f94bb7..26b1668 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -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, tm
say({ 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 };
ddiff --git a/migrations/0025_knowledge.sql b/migrations/0025_knowledge.sql
nnew file mode 100644
iindex 0000000..6201b31
---- /dev/null
++++ b/migrations/0025_knowledge.sql
@@@ -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;
ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex 2833abe..a68352f 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -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}}
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 7dfd62e..0001d5e 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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.
ddiff --git a/server/app.js b/server/app.js
iindex 0cc4d74..691d4f7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/domain/knowledge.js b/server/domain/knowledge.js
nnew file mode 100644
iindex 0000000..0ae79be
---- /dev/null
++++ b/server/domain/knowledge.js
@@@ -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,
++};
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex e7b03cc..3d4b390 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -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, ou
else 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, ou
const 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) });
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex f4b4441..c5370a4 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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 },
};
/**
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 20af8d6..dc09101 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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, {
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 7fd9b07..18bc96e 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -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'),
ddiff --git a/server/http/knowledge-api.js b/server/http/knowledge-api.js
nnew file mode 100644
iindex 0000000..9d74286
---- /dev/null
++++ b/server/http/knowledge-api.js
@@@ -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 };
ddiff --git a/server/http/knowledge.js b/server/http/knowledge.js
nnew file mode 100644
iindex 0000000..3aa9eb0
---- /dev/null
++++ b/server/http/knowledge.js
@@@ -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 };
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex a717f70..c4d1423 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -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}/`);
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 0f2c96f..a7427e0 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex a9120f6..1cc5615 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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`);
ddiff --git a/test/knowledge.test.js b/test/knowledge.test.js
nnew file mode 100644
iindex 0000000..ffe6ad4
---- /dev/null
++++ b/test/knowledge.test.js
@@@ -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); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex e6fb132..6915122 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex cacdf43..061fb53 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',