negotium 0.2.18 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/agent-helpers.js +367 -313
  2. package/dist/agent-helpers.js.map +18 -18
  3. package/dist/background-bash.js +3 -2
  4. package/dist/background-bash.js.map +3 -3
  5. package/dist/browser-runtime.js +60 -36
  6. package/dist/browser-runtime.js.map +6 -6
  7. package/dist/{chunk-fc58cqry.js → chunk-g2kqfdwm.js} +154 -24
  8. package/dist/{chunk-fc58cqry.js.map → chunk-g2kqfdwm.js.map} +7 -6
  9. package/dist/hosted-agent.js +183 -53
  10. package/dist/hosted-agent.js.map +8 -7
  11. package/dist/main.js +1395 -626
  12. package/dist/main.js.map +27 -26
  13. package/dist/mcp-factories.js +1204 -624
  14. package/dist/mcp-factories.js.map +21 -20
  15. package/dist/prompts.js +3 -2
  16. package/dist/prompts.js.map +3 -3
  17. package/dist/query-runtime.js +47 -26
  18. package/dist/query-runtime.js.map +4 -4
  19. package/dist/registry.js +3 -3
  20. package/dist/registry.js.map +2 -2
  21. package/dist/rollout.js +1 -1
  22. package/dist/runtime/src/agents/archiver.ts +4 -3
  23. package/dist/runtime/src/application/switch-topic-access-mode.ts +81 -8
  24. package/dist/runtime/src/bus.ts +19 -2
  25. package/dist/runtime/src/mcp/canonical-proxy-server.ts +13 -14
  26. package/dist/runtime/src/mcp/session-comm/runtime.ts +16 -0
  27. package/dist/runtime/src/mcp/wiki-search-index.ts +359 -0
  28. package/dist/runtime/src/mcp/wiki-server.ts +559 -98
  29. package/dist/runtime/src/platform/config.ts +13 -1
  30. package/dist/runtime/src/platform/mcp-config.ts +1 -1
  31. package/dist/runtime/src/prompts/agents/wiki-archiver.md +62 -47
  32. package/dist/runtime/src/runtime/ask-callbacks.ts +19 -11
  33. package/dist/runtime/src/runtime/background-sessions.ts +9 -7
  34. package/dist/runtime/src/runtime/visual-store.ts +31 -23
  35. package/dist/runtime/src/storage/api-topics.ts +26 -0
  36. package/dist/runtime/src/storage/browser-profiles.ts +16 -8
  37. package/dist/runtime/src/storage/runtime-events.ts +30 -20
  38. package/dist/runtime/src/storage/runtime-gateway-submissions.ts +24 -16
  39. package/dist/runtime/src/storage/runtime-turn-requests.ts +9 -1
  40. package/dist/runtime/src/storage/self-schedules.ts +32 -24
  41. package/dist/runtime/src/storage/storage-host.ts +105 -43
  42. package/dist/runtime/src/storage/vault.ts +12 -3
  43. package/dist/runtime/src/version.ts +1 -1
  44. package/dist/runtime-helpers.js +47 -26
  45. package/dist/runtime-helpers.js.map +4 -4
  46. package/dist/storage.js +76 -41
  47. package/dist/storage.js.map +4 -4
  48. package/dist/types/packages/core/src/agents/archiver.d.ts +1 -1
  49. package/dist/types/packages/core/src/mcp/wiki-search-index.d.ts +59 -0
  50. package/dist/types/packages/core/src/storage/api-topics.d.ts +15 -0
  51. package/dist/types/packages/core/src/storage/storage-host.d.ts +10 -0
  52. package/dist/types/packages/core/src/version.d.ts +1 -1
  53. package/dist/vault.js +140 -10
  54. package/dist/vault.js.map +6 -5
  55. package/package.json +1 -1
@@ -114,11 +114,23 @@ function versionAtLeast(actualVersion: string, minimumVersion: string): boolean
114
114
  return true;
115
115
  }
116
116
 
117
+ /**
118
+ * Ceiling for the `--version` probe, not a latency expectation.
119
+ *
120
+ * The probe exists so a wedged binary cannot hang startup; a healthy one
121
+ * answers in milliseconds. At 2s it also failed whenever the machine was merely
122
+ * busy — process startup under a loaded test suite regularly exceeds that —
123
+ * which turned a correctness check into a load measurement and made
124
+ * `resolveBrowserRsBin` intermittently report a perfectly good binary as
125
+ * unusable. Generous enough that only a genuinely stuck process trips it.
126
+ */
127
+ const BROWSER_RS_VERSION_PROBE_TIMEOUT_MS = 15_000;
128
+
117
129
  function browserRsMeetsMinimumVersion(candidate: string): boolean {
118
130
  try {
119
131
  const output = execFileSync(candidate, ["--version"], {
120
132
  encoding: "utf8",
121
- timeout: 2_000,
133
+ timeout: BROWSER_RS_VERSION_PROBE_TIMEOUT_MS,
122
134
  stdio: ["ignore", "pipe", "ignore"],
123
135
  }).trim();
124
136
  const match = output.match(/^browser-rs (\d+)\.(\d+)\.(\d+)$/);
@@ -474,7 +474,7 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
474
474
  }
475
475
  const args = [`--user-id=${userId}`];
476
476
  if (topicId) args.push(`--current-topic-id=${topicId}`);
477
- // Prefer the REST topic id so save_wiki_entry writes filenames and SQLite
477
+ // Prefer the REST topic id so wiki_write writes filenames and SQLite
478
478
  // briefs keyed the same way the topic wiki API reads them.
479
479
  const resolvedWikiTopicId =
480
480
  wikiTopicId ?? topicId ?? (session !== "dm" ? session : undefined);
@@ -1,32 +1,38 @@
1
1
  ---
2
2
  name: wiki-archiver
3
3
  type: programmatic
4
- description: Agent that extracts key content from session logs and updates wiki/summaries/, wiki/articles/, wiki/topic/, wiki/skills/, wiki/article-index.md, wiki/topic-index.md, and wiki/skill-index.md
4
+ description: Agent that extracts key content from session logs and updates wiki/summaries/, wiki/articles/, wiki/topic/, wiki/skills/, wiki/summary-index.md, wiki/article-index.md, wiki/topic-index.md, and wiki/skill-index.md
5
5
  model: deepseek-pro
6
6
  tools:
7
7
  - Read
8
- - Write
9
8
  - Glob
10
- - mcp__wiki__save_wiki_entry
11
- - mcp__wiki__save_topic_brief
9
+ - mcp__wiki__wiki_write
10
+ - mcp__wiki__wiki_read
12
11
  - mcp__wiki__skill_save
13
12
  - mcp__wiki__skill_query
14
13
  - mcp__wiki__index_upsert
15
14
  ---
16
15
 
17
16
  You are a wiki archiver agent. Extract key information from session logs and save it into the
18
- wiki knowledge base. The wiki has **two index files** that must be kept in sync with the underlying
19
- content directories:
17
+ wiki knowledge base. Every document has exactly one catalog, and **`wiki_write` writes the document
18
+ and its catalog row in the same call** — so you cannot leave a document unindexed:
20
19
 
21
20
  ```
22
21
  wiki/
23
- summaries/<date>-<topic>.md <- session summaries (write-once)
24
- articles/<slug>.md <- curated concept pages (mergeable)
25
- topic/<topic>.md <- accumulated persona brief (one file per title)
26
- article-index.md <- catalog: articles + summaries
27
- topic-index.md <- catalog: topic briefs only
22
+ summaries/<date>-<topic>.md <- session summaries (write-once) -> summary-index.md
23
+ articles/<slug>.md <- curated concept pages (mergeable) -> article-index.md
24
+ topic/<topic>.md <- accumulated persona brief -> topic-index.md
25
+ .wiki-search-index.sqlite <- derived body-search cache (never edit or archive)
28
26
  ```
29
27
 
28
+ `wiki_write` also feeds the derived cache, so a document is searchable by its body the moment it is
29
+ written. The cache is disposable: if a call reports that it was not updated, the catalogs still
30
+ work and `wiki_reindex` refills it.
31
+
32
+ **You have no file-writing tool.** `wiki_write` is the only way to create or update a wiki
33
+ document; `description` is mandatory because the catalog is what makes retrieval work. Use `Read`
34
+ and `Glob` to inspect existing files before merging.
35
+
30
36
  ## Output language
31
37
 
32
38
  Write all human-readable prose (summaries, the persona brief, article bodies, skill descriptions, your final report) in the `output_language:` given in the prompt; default to **English** if absent. `output_language` tracks the user's mother tongue (e.g. `English`, `Korean`, `한국어`, `Japanese`).
@@ -40,7 +46,7 @@ Keep structural tokens in English regardless: frontmatter keys, `type:` values,
40
46
  - Read the first chunk with `Read(archive_path, offset: 1, limit: 2000)`.
41
47
  - If the result ends with a truncation notice like `lines X-Y of N`, call the next chunk with `offset = Y + 1`. Stop when there is no notice or `Y == N`.
42
48
  - For each chunk, **only accumulate** key items (decisions / facts / tools / files / patterns …) into a short in-memory bullet buffer.
43
- - **Do not call wiki write / save / index_upsert per chunk.** After reading every chunk, run steps 3 onward exactly once against the accumulated buffer. Saving per chunk splits one session across multiple summary files and piles up duplicate index_upsert calls.
49
+ - **Do not call `wiki_write` per chunk.** After reading every chunk, run steps 3 onward exactly once against the accumulated buffer. Writing per chunk splits one session across multiple summary files.
44
50
  - If the buffer grows large (e.g. 3000+ bullets, ≥ 5 chunks), compress/drop trivial earlier items and keep only the essential decisions / facts / patterns. Preserving cross-references through the last chunk takes priority.
45
51
 
46
52
  2. **Extract** key information (decisions, facts, patterns, tools — skip greetings, debug noise, repeated questions).
@@ -55,22 +61,28 @@ Keep structural tokens in English regardless: frontmatter keys, `type:` values,
55
61
  - Use `canonical_topic` for every summary, brief, and topic-index write below. This keeps a
56
62
  differently named room's summary and persona update in one canonical namespace.
57
63
  - If `sent_files:` is in the prompt, include those entries under `## Files Sent`
58
- - If the session yielded **no extractable substance** (pure debug, ≤2 short exchanges, all greeting), save only a single-line immutable summary via `save_wiki_entry`, then STOP. Do not modify the accumulated persona brief, articles, or indexes.
64
+ - If the session yielded **no extractable substance** (pure debug, ≤2 short exchanges, all greeting), save only a single-line immutable summary via `wiki_write(kind="summary", ...)`, then STOP. Do not touch the persona brief or articles.
59
65
 
60
66
  > **Ordering principle:** pipeline is **archive → summary → brief**. The summary is *this* session's raw distillation; the persona brief is the slow-moving user model that folds each summary in. Save the summary first and update the brief last, so the brief sees everything the session produced.
61
67
 
62
- 3. **Save the immutable session summary** via `mcp__wiki__save_wiki_entry(canonical_topic, content)`.
63
- The MCP handles file naming + dedup → returns the saved path (e.g. `wiki/summaries/2026-05-08-dev.md`).
64
- It also records `latest_summary_md` + `summary_date` in SQLite (it does **not** touch the brief —
65
- that is done in step 5 via `save_topic_brief`). Use the **summary format** below.
68
+ 3. **Save the immutable session summary** via
69
+ `mcp__wiki__wiki_write(kind="summary", topic=canonical_topic, content, description)`.
70
+ The MCP handles file naming + dedup, writes the `summary-index.md` row from your `description`,
71
+ and records `latest_summary_md` + `summary_date` in SQLite (it does **not** touch the brief —
72
+ that is step 5). Use the **summary format** below.
66
73
  4. **Update articles** — for each genuinely reusable concept/decision/tool/pattern:
67
- - Glob existing articles: `Glob(wiki/articles/*.md)`
68
- - If a matching article exists (by slug or topic): Read it, then Write merged content.
74
+ - Glob existing articles: `Glob(wiki/articles/*.md)`, and `Read(wiki/article-index.md)` once to
75
+ see the existing `## ...` sections.
76
+ - If a matching article exists (by slug or topic): `Read` it, merge, then write the merged body.
69
77
  - **Preserve frontmatter `date:` (first-seen) and `status:`.** Only refresh `updated:`.
70
78
  - **Preserve manually written body sections.** Only append/update what the session adds.
71
- - If new: Write `wiki/articles/<slug>.md` using the **article format** below.
79
+ - Write with
80
+ `wiki_write(kind="article", slug=<kebab-slug>, section=<H2 without "## ">, content, description)`.
81
+ `section` is required: pick the closest existing header, or a short new domain title in
82
+ `output_language` (e.g. `Business / Career`, `Physical AI / Robotics`).
72
83
  - Skip session-specific noise. If nothing qualifies, no articles change — that's fine.
73
- 5. **Update the accumulated persona brief last** via `mcp__wiki__save_topic_brief(canonical_topic, content)`.
84
+ 5. **Update the accumulated persona brief last** via
85
+ `mcp__wiki__wiki_write(kind="topic", topic=canonical_topic, content, description)`.
74
86
  This is the culmination of the run — the brief is not a worklog, it is the wiki's evolving
75
87
  **persona/user-model** for this topic: who the user is, how they want to be served, and where
76
88
  things stand. It is injected verbatim at the next session's start, so write it as durable memory,
@@ -81,31 +93,23 @@ Keep structural tokens in English regardless: frontmatter keys, `type:` values,
81
93
  Decisions into the **persona layer** (accumulate — the user-model is slow-moving). Refresh only
82
94
  the volatile layers (`## Recent Work`, `## Current State`) from this session. Preserve still-valid
83
95
  prior persona traits; remove a trait only when the new session explicitly supersedes it.
84
- - Keep one canonical file per topic memory key — `save_topic_brief` handles the path + SQLite mirror.
85
- Never add a UUID or room id. Write a fresh compact brief using the **brief format** below.
86
- 6. **Update the dual indexes via `mcp__wiki__index_upsert` — one call per entry.**
87
- The MCP handles in-place updates, section insertion, and the `created` vs `updated` date split. Do **not** Read/Write the index files manually.
88
-
89
- **For each new or updated article** (from step 4):
90
- - First, scan `wiki/article-index.md` once with `Read` to see existing `## ...` headers, then pick the closest matching section. If no section fits, choose a short domain title in `output_language` (e.g. `Business / Career`, `Physical AI / Robotics`) — the MCP will create the new H2 above `## Source Summaries`.
91
- - Call: `index_upsert(slug=<article-slug>, description=<one-line>, kind="article", section=<chosen-header-without-"## ">)`
92
- - The MCP preserves the original `created` date on update — do not pass it.
93
-
94
- **For the new session summary** (from step 3):
95
- - Call: `index_upsert(slug=<summary-slug>, description=<one-line>, kind="summary")`
96
- - Goes under `## Source Summaries` automatically.
96
+ - Keep one canonical file per topic memory key — `wiki_write` handles the path, the
97
+ `topic-index.md` row, and the SQLite mirror. Never add a UUID or room id. Write a fresh compact
98
+ brief using the **brief format** below, and let `description` be a one-line state of recent work.
99
+ 6. **There is no separate indexing step.** Steps 3–5 already wrote every catalog row, so the
100
+ catalogs cannot drift from the documents. Two rules remain:
97
101
 
98
- **For this session's topic brief** (from step 5):
99
- - Call: `index_upsert(slug=<canonical_topic>, description=<one-line summary of recent work>, kind="topic")`
100
- - Pass the bare canonical key (no `topic/` prefix); the MCP wikilinks it as `[[topic/<canonical_topic>]]`.
102
+ - **`description` carries the retrieval weight.** Write it as the one line you would want to see
103
+ when searching six months from now: what changed and why it matters, not "session summary".
104
+ - **`index_upsert` is for corrections only.** It can refine the description or section of an entry
105
+ that already exists, and it refuses to create a row for a document that was never written.
106
+ **Never delete entries** — pruning is a `wiki lint` concern.
101
107
 
102
- **Never delete entries** — `index_upsert` is insert-or-update only; pruning is a `wiki lint` concern.
103
-
104
- ## Section rules (recap, used when calling `index_upsert(kind="article", section=...)`)
108
+ ## Section rules (recap, used for `wiki_write(kind="article", section=...)`)
105
109
 
106
110
  1. Scan existing `## ...` headers in `article-index.md` first. Pick the closest match.
107
- 2. If none fits, pass a short title in `output_language` for the article's domain — the MCP inserts a new H2 above `## Source Summaries`.
108
- 3. `## Source Summaries` is always the last section; don't try to push articles below it.
111
+ 2. If none fits, pass a short title in `output_language` for the article's domain — the MCP appends
112
+ the new H2. Do not rely on a particular ordering of sections.
109
113
 
110
114
  ## wiki/summaries/ entry format
111
115
 
@@ -226,9 +230,21 @@ _Last updated: {YYYY-MM-DD}_
226
230
 
227
231
  ## {Domain section 2}
228
232
  - ...
233
+ ```
234
+
235
+ ## summary-index.md structure (skeleton)
236
+
237
+ ```
238
+ ---
239
+ {frontmatter — preserve as-is}
240
+ ---
241
+
242
+ # Wiki Index — Summaries
243
+
244
+ _Last updated: {YYYY-MM-DD}_
229
245
 
230
- ## Source Summaries
231
- - [[2026-05-08-topic]] — desc (2026-05-08)
246
+ ## Session Summaries
247
+ - [[summaries/2026-05-08-topic]] — desc (2026-05-08)
232
248
  - ...
233
249
  ```
234
250
 
@@ -302,7 +318,6 @@ description: "keyword1, keyword2, likely trigger phrases the user would type —
302
318
  Summarize in `output_language` (report in pipeline order — summary → articles → brief):
303
319
  - 📝 summary: saved `wiki/summaries/<filename>`
304
320
  - 📄 articles: created/updated N pages (slugs)
305
- - 🗂 brief: `wiki/topic/<topic>.md` persona merge (`save_topic_brief`)
306
- - 📇 article-index: N `index_upsert` calls (article + summary)
307
- - 📇 topic-index: 1 `index_upsert` call (this topic)
321
+ - 🗂 brief: `wiki/topic/<topic>.md` persona merge
322
+ - 📇 catalogs: summary-index 1 row, article-index N rows, topic-index 1 row (all written by `wiki_write`)
308
323
  - 🛠 skill: created/updated `wiki/skills/<name>/skill.md` (or 'none')
@@ -12,18 +12,26 @@
12
12
  import { deliverPeerReply, type RemoteReplyRoute } from "#mcp/session-comm/peer-forward";
13
13
  import { db } from "#storage/forum-db";
14
14
  import { PENDING_ASK_TTL_MS, type PendingAskUserId } from "#storage/session-asks";
15
+ import { registerStorageSchemaInitializer } from "#storage/storage-host";
15
16
 
16
- db.exec(`
17
- CREATE TABLE IF NOT EXISTS remote_ask_callbacks (
18
- target_query_id TEXT PRIMARY KEY,
19
- request_id TEXT NOT NULL,
20
- node_name TEXT NOT NULL,
21
- node_cell_id TEXT NOT NULL,
22
- topic_id TEXT NOT NULL,
23
- user_id TEXT NOT NULL,
24
- created_at INTEGER NOT NULL
25
- )
26
- `);
17
+ // Registered rather than executed at import time. A bare top-level `db.exec`
18
+ // resolves the storage connection the moment this module is imported, which for
19
+ // an embedding host is before `configureStorageHost()` has run — Negotium then
20
+ // opens its own database in the default state directory, caches it, and every
21
+ // later caller silently keeps using it instead of the host's.
22
+ registerStorageSchemaInitializer((database) => {
23
+ database.exec(`
24
+ CREATE TABLE IF NOT EXISTS remote_ask_callbacks (
25
+ target_query_id TEXT PRIMARY KEY,
26
+ request_id TEXT NOT NULL,
27
+ node_name TEXT NOT NULL,
28
+ node_cell_id TEXT NOT NULL,
29
+ topic_id TEXT NOT NULL,
30
+ user_id TEXT NOT NULL,
31
+ created_at INTEGER NOT NULL
32
+ )
33
+ `);
34
+ });
27
35
 
28
36
  interface PendingAskIdentity {
29
37
  userId: PendingAskUserId;
@@ -1,4 +1,5 @@
1
1
  import { listActiveMemoryArchiverSessions } from "#agents/archiver";
2
+ import { summarizeDisplayText } from "#agents/tool-format";
2
3
  import { getTopic } from "#storage/api-topics";
3
4
  import { listRecentRuntimeEventsForTopic } from "#storage/runtime-events";
4
5
  import { listRuntimeTurnLeases } from "#storage/runtime-leases";
@@ -127,7 +128,7 @@ function detailedToolStep(payload: Record<string, unknown>): string {
127
128
 
128
129
  if (shortName === "ask_session" || shortName === "tell_session") {
129
130
  const target = typeof input.to === "string" && input.to.trim() ? input.to.trim() : "session";
130
- const msg = typeof input.message === "string" ? text(input.message) : "";
131
+ const msg = typeof input.message === "string" ? summarizeDisplayText(text(input.message)) : "";
131
132
  const action = shortName === "ask_session" ? "Ask" : "Tell";
132
133
  return `${action} ${target}${msg ? ` — ${msg}` : ""}`;
133
134
  }
@@ -150,20 +151,21 @@ function detailedToolStep(payload: Record<string, unknown>): string {
150
151
  return `${action} file`;
151
152
  }
152
153
  if (shortName === "grep" || shortName === "glob") {
153
- const pattern = typeof input.pattern === "string" ? input.pattern : "";
154
+ const pattern =
155
+ typeof input.pattern === "string" ? summarizeDisplayText(text(input.pattern)) : "";
154
156
  const action = shortName === "grep" ? "Grep" : "Glob";
155
- if (pattern) return `${action} '${text(pattern)}'${path ? ` in ${path}` : ""}`;
157
+ if (pattern) return `${action} '${pattern}'${path ? ` in ${path}` : ""}`;
156
158
  if (path) return `${action} ${path}`;
157
159
  }
158
160
  if (shortName === "bash") {
159
- const cmd = typeof input.command === "string" ? text(input.command) : "";
161
+ const cmd = typeof input.command === "string" ? summarizeDisplayText(text(input.command)) : "";
160
162
  return cmd ? `Bash: ${cmd}` : "Bash";
161
163
  }
162
164
  if (shortName === "webfetch" || shortName === "url_fetch") {
163
- const url = typeof input.url === "string" ? text(input.url) : "";
165
+ const url = typeof input.url === "string" ? summarizeDisplayText(text(input.url)) : "";
164
166
  return url ? `Fetch ${url}` : "WebFetch";
165
167
  }
166
- return label;
168
+ return summarizeDisplayText(label);
167
169
  }
168
170
 
169
171
  export function backgroundSessionProgress(
@@ -190,7 +192,7 @@ export function backgroundSessionProgress(
190
192
  else if (kind === "tool_output") {
191
193
  const toolName = toolNames.get(text(payload.toolUseId)) ?? "Tool";
192
194
  step = payload.isError
193
- ? `${toolName} failed: ${text(payload.content) || "unknown error"}`
195
+ ? `${toolName} failed: ${summarizeDisplayText(text(payload.content)) || "unknown error"}`
194
196
  : `${toolName} result · ${byteSize(payload.content)}`;
195
197
  } else if (kind === "ai_done") {
196
198
  const usage =
@@ -10,34 +10,42 @@
10
10
  */
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { db } from "#storage/forum-db";
13
+ import { registerStorageSchemaInitializer } from "#storage/storage-host";
13
14
 
14
15
  const MAX_HISTORY = 10;
15
16
  const MAX_VISUAL_TITLE_CHARS = 180;
16
17
  export const VISUAL_MEDIA_URL_PLACEHOLDER = "__NEGOTIUM_VISUAL_MEDIA_URL__";
17
18
 
18
- db.exec(`
19
- CREATE TABLE IF NOT EXISTS api_topic_visuals (
20
- id INTEGER PRIMARY KEY AUTOINCREMENT,
21
- topic_id TEXT NOT NULL,
22
- html TEXT NOT NULL,
23
- title TEXT,
24
- created_at INTEGER NOT NULL,
25
- kind TEXT NOT NULL DEFAULT 'html',
26
- source TEXT,
27
- file_id TEXT,
28
- mime_type TEXT,
29
- media_token TEXT
30
- );
31
- CREATE INDEX IF NOT EXISTS idx_api_topic_visuals_topic
32
- ON api_topic_visuals(topic_id, created_at DESC);
33
-
34
- CREATE TABLE IF NOT EXISTS api_topic_visual_views (
35
- topic_id TEXT NOT NULL,
36
- user_id TEXT NOT NULL,
37
- visual_id INTEGER NOT NULL,
38
- PRIMARY KEY (topic_id, user_id)
39
- );
40
- `);
19
+ // Registered rather than executed at import time. A bare top-level `db.exec`
20
+ // resolves the storage connection the moment this module is imported, which for
21
+ // an embedding host is before `configureStorageHost()` has run — Negotium then
22
+ // opens its own database in the default state directory, caches it, and every
23
+ // later caller silently keeps using it instead of the host's.
24
+ registerStorageSchemaInitializer((database) => {
25
+ database.exec(`
26
+ CREATE TABLE IF NOT EXISTS api_topic_visuals (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ topic_id TEXT NOT NULL,
29
+ html TEXT NOT NULL,
30
+ title TEXT,
31
+ created_at INTEGER NOT NULL,
32
+ kind TEXT NOT NULL DEFAULT 'html',
33
+ source TEXT,
34
+ file_id TEXT,
35
+ mime_type TEXT,
36
+ media_token TEXT
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_api_topic_visuals_topic
39
+ ON api_topic_visuals(topic_id, created_at DESC);
40
+
41
+ CREATE TABLE IF NOT EXISTS api_topic_visual_views (
42
+ topic_id TEXT NOT NULL,
43
+ user_id TEXT NOT NULL,
44
+ visual_id INTEGER NOT NULL,
45
+ PRIMARY KEY (topic_id, user_id)
46
+ );
47
+ `);
48
+ });
41
49
 
42
50
  for (const statement of [
43
51
  "ALTER TABLE api_topic_visuals ADD COLUMN kind TEXT NOT NULL DEFAULT 'html'",
@@ -705,6 +705,32 @@ export function upsertTopic(t: TopicDto): void {
705
705
  })();
706
706
  }
707
707
 
708
+ /**
709
+ * Apply one access mode to a set of topics as a single all-or-nothing write.
710
+ *
711
+ * A partial write is not a cosmetic glitch here: a public parent left with
712
+ * private subagent children (or the reverse) is exactly the half-exposed state
713
+ * the access-mode cascade exists to prevent, so every row commits or none do.
714
+ *
715
+ * Deliberately a narrow UPDATE instead of a loop over {@link upsertTopic}.
716
+ * That helper opens its own transaction, and the node:sqlite shim in
717
+ * `sqlite.ts` emulates transactions with bare BEGIN/COMMIT rather than
718
+ * savepoints — nesting one inside an outer transaction would fail outright.
719
+ * It also rewrites participants and browser-profile ownership, none of which
720
+ * an access-mode change should touch.
721
+ */
722
+ export function setTopicAccessModes(
723
+ topicIds: readonly string[],
724
+ accessMode: TopicAccessMode,
725
+ ): void {
726
+ if (topicIds.length === 0) return;
727
+ const normalized = normalizeTopicAccessMode(accessMode);
728
+ db.transaction(() => {
729
+ const update = db.query("UPDATE api_topics SET access_mode = ? WHERE id = ?");
730
+ for (const topicId of topicIds) update.run(normalized, topicId);
731
+ })();
732
+ }
733
+
708
734
  export function listTopics(): TopicDto[] {
709
735
  const rows = db
710
736
  .query("SELECT * FROM api_topics ORDER BY last_message_at DESC")
@@ -1,4 +1,5 @@
1
1
  import { db } from "#storage/forum-db";
2
+ import { registerStorageSchemaInitializer } from "#storage/storage-host";
2
3
 
3
4
  export const DEFAULT_BROWSER_PROFILE = "default";
4
5
 
@@ -17,14 +18,21 @@ export function normalizeBrowserProfileName(name: string): string {
17
18
  return value;
18
19
  }
19
20
 
20
- db.exec(`
21
- CREATE TABLE IF NOT EXISTS browser_profiles (
22
- owner_id TEXT NOT NULL,
23
- name TEXT NOT NULL,
24
- created_at TEXT NOT NULL,
25
- PRIMARY KEY (owner_id, name)
26
- )
27
- `);
21
+ // Registered rather than executed at import time. A bare top-level `db.exec`
22
+ // resolves the storage connection the moment this module is imported, which for
23
+ // an embedding host is before `configureStorageHost()` has run — Negotium then
24
+ // opens its own database in the default state directory, caches it, and every
25
+ // later caller silently keeps using it instead of the host's.
26
+ registerStorageSchemaInitializer((database) => {
27
+ database.exec(`
28
+ CREATE TABLE IF NOT EXISTS browser_profiles (
29
+ owner_id TEXT NOT NULL,
30
+ name TEXT NOT NULL,
31
+ created_at TEXT NOT NULL,
32
+ PRIMARY KEY (owner_id, name)
33
+ )
34
+ `);
35
+ });
28
36
 
29
37
  export function getBrowserProfileOwner(topicId: string, fallbackUserId: string): string {
30
38
  return (
@@ -1,4 +1,5 @@
1
1
  import { db } from "#storage/forum-db";
2
+ import { registerStorageSchemaInitializer } from "#storage/storage-host";
2
3
 
3
4
  export const RUNTIME_EVENT_TYPES = [
4
5
  "message",
@@ -29,26 +30,35 @@ interface RuntimeEventRow {
29
30
  created_at: string;
30
31
  }
31
32
 
32
- db.exec(`
33
- CREATE TABLE IF NOT EXISTS runtime_events (
34
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
35
- source_id TEXT NOT NULL,
36
- event_type TEXT NOT NULL CHECK (
37
- event_type IN (
38
- 'message',
39
- 'message-updated',
40
- 'ai-status',
41
- 'topic-created',
42
- 'topic-updated',
43
- 'topic-deleted'
44
- )
45
- ),
46
- topic_id TEXT NOT NULL,
47
- payload_json TEXT NOT NULL,
48
- created_at TEXT NOT NULL
49
- )
50
- `);
51
- db.exec("CREATE INDEX IF NOT EXISTS idx_runtime_events_topic_seq ON runtime_events(topic_id, seq)");
33
+ // Registered rather than executed at import time. A bare top-level `db.exec`
34
+ // resolves the storage connection the moment this module is imported, which for
35
+ // an embedding host is before `configureStorageHost()` has run — Negotium then
36
+ // opens its own database in the default state directory, caches it, and every
37
+ // later caller silently keeps using it instead of the host's.
38
+ registerStorageSchemaInitializer((database) => {
39
+ database.exec(`
40
+ CREATE TABLE IF NOT EXISTS runtime_events (
41
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ source_id TEXT NOT NULL,
43
+ event_type TEXT NOT NULL CHECK (
44
+ event_type IN (
45
+ 'message',
46
+ 'message-updated',
47
+ 'ai-status',
48
+ 'topic-created',
49
+ 'topic-updated',
50
+ 'topic-deleted'
51
+ )
52
+ ),
53
+ topic_id TEXT NOT NULL,
54
+ payload_json TEXT NOT NULL,
55
+ created_at TEXT NOT NULL
56
+ )
57
+ `);
58
+ database.exec(
59
+ "CREATE INDEX IF NOT EXISTS idx_runtime_events_topic_seq ON runtime_events(topic_id, seq)",
60
+ );
61
+ });
52
62
 
53
63
  const types = new Set<string>(RUNTIME_EVENT_TYPES);
54
64
 
@@ -1,4 +1,5 @@
1
1
  import { db } from "#storage/forum-db";
2
+ import { registerStorageSchemaInitializer } from "#storage/storage-host";
2
3
 
3
4
  export interface RuntimeGatewaySubmission {
4
5
  clientMessageId: string;
@@ -25,19 +26,29 @@ interface RuntimeGatewaySubmissionRow {
25
26
  payload_hash: string | null;
26
27
  }
27
28
 
28
- db.exec(`
29
- CREATE TABLE IF NOT EXISTS runtime_gateway_submissions (
30
- client_message_id TEXT PRIMARY KEY,
31
- request_id TEXT NOT NULL UNIQUE,
32
- topic_id TEXT NOT NULL,
33
- message_id TEXT NOT NULL,
34
- user_id TEXT NOT NULL,
35
- created_at TEXT NOT NULL,
36
- ack_cursor INTEGER NOT NULL DEFAULT 0,
37
- message_cursor INTEGER NOT NULL DEFAULT 0,
38
- payload_hash TEXT
39
- )
40
- `);
29
+ // Registered rather than executed at import time. A bare top-level `db.exec`
30
+ // resolves the storage connection the moment this module is imported, which for
31
+ // an embedding host is before `configureStorageHost()` has run — Negotium then
32
+ // opens its own database in the default state directory, caches it, and every
33
+ // later caller silently keeps using it instead of the host's.
34
+ registerStorageSchemaInitializer((database) => {
35
+ database.exec(`
36
+ CREATE TABLE IF NOT EXISTS runtime_gateway_submissions (
37
+ client_message_id TEXT PRIMARY KEY,
38
+ request_id TEXT NOT NULL UNIQUE,
39
+ topic_id TEXT NOT NULL,
40
+ message_id TEXT NOT NULL,
41
+ user_id TEXT NOT NULL,
42
+ created_at TEXT NOT NULL,
43
+ ack_cursor INTEGER NOT NULL DEFAULT 0,
44
+ message_cursor INTEGER NOT NULL DEFAULT 0,
45
+ payload_hash TEXT
46
+ )
47
+ `);
48
+ database.exec(
49
+ "CREATE INDEX IF NOT EXISTS idx_runtime_gateway_submissions_topic ON runtime_gateway_submissions(topic_id)",
50
+ );
51
+ });
41
52
  try {
42
53
  db.exec(
43
54
  "ALTER TABLE runtime_gateway_submissions ADD COLUMN ack_cursor INTEGER NOT NULL DEFAULT 0",
@@ -51,9 +62,6 @@ try {
51
62
  "ALTER TABLE runtime_gateway_submissions ADD COLUMN message_cursor INTEGER NOT NULL DEFAULT 0",
52
63
  );
53
64
  } catch {}
54
- db.exec(
55
- "CREATE INDEX IF NOT EXISTS idx_runtime_gateway_submissions_topic ON runtime_gateway_submissions(topic_id)",
56
- );
57
65
 
58
66
  function rowToSubmission(row: RuntimeGatewaySubmissionRow): RuntimeGatewaySubmission {
59
67
  return {
@@ -9,6 +9,7 @@ import { db } from "#storage/forum-db";
9
9
  import { TURN_LEASE_STALE_MS } from "#storage/runtime-leases";
10
10
  import { getRuntimeTopicEpoch, TOPIC_MAINTENANCE_STALE_MS } from "#storage/runtime-topic-state";
11
11
  import type { StorageDatabase } from "#storage/storage-contract";
12
+ import { registerStorageSchemaInitializer } from "#storage/storage-host";
12
13
  import type { AgentKind, EffortLevel, PeerRuntimeBridgeContext } from "#types";
13
14
 
14
15
  const REQUEST_CLAIM_STALE_MS = TURN_LEASE_STALE_MS;
@@ -149,7 +150,14 @@ export function ensureRuntimeUserTurnRequestsSchema(database: StorageDatabase):
149
150
  );
150
151
  }
151
152
 
152
- ensureRuntimeUserTurnRequestsSchema(db);
153
+ // Registered rather than run at import time. Calling this with `db` on module
154
+ // evaluation resolves the storage connection immediately, which for an
155
+ // embedding host happens before `configureStorageHost()` — Negotium then opens
156
+ // and caches its own database in the default state directory, and every later
157
+ // caller silently keeps using it instead of the host's.
158
+ registerStorageSchemaInitializer((database) =>
159
+ ensureRuntimeUserTurnRequestsSchema(database as unknown as StorageDatabase),
160
+ );
153
161
 
154
162
  function rowToRequest(row: RuntimeUserTurnRequestRow): RuntimeUserTurnRequest {
155
163
  let attachments: string[] | undefined;