negotium 0.2.17 → 0.2.19

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 (38) hide show
  1. package/dist/agent-helpers.js +41 -32
  2. package/dist/agent-helpers.js.map +10 -10
  3. package/dist/{chunk-5cpdbbeh.js → chunk-fc58cqry.js} +3 -1
  4. package/dist/{chunk-5cpdbbeh.js.map → chunk-fc58cqry.js.map} +3 -3
  5. package/dist/hosted-agent.js +4 -2
  6. package/dist/hosted-agent.js.map +4 -4
  7. package/dist/main.js +1377 -285
  8. package/dist/main.js.map +14 -13
  9. package/dist/mcp-factories.js +1497 -403
  10. package/dist/mcp-factories.js.map +14 -13
  11. package/dist/prompts.js +8 -8
  12. package/dist/prompts.js.map +3 -3
  13. package/dist/registry.js +3 -3
  14. package/dist/registry.js.map +2 -2
  15. package/dist/rollout.js +1 -1
  16. package/dist/runtime/src/agents/archiver.ts +1 -1
  17. package/dist/runtime/src/agents/idle-archiver.ts +3 -2
  18. package/dist/runtime/src/mcp/canonical-proxy-server.ts +31 -15
  19. package/dist/runtime/src/mcp/wiki-search-index.ts +359 -0
  20. package/dist/runtime/src/mcp/wiki-server.ts +1358 -155
  21. package/dist/runtime/src/platform/mcp-config.ts +2 -1
  22. package/dist/runtime/src/prompts/agents/wiki-archiver.md +73 -48
  23. package/dist/runtime/src/prompts/builders.ts +17 -34
  24. package/dist/runtime/src/runtime/turn-runner.ts +14 -17
  25. package/dist/runtime/src/storage/api-topics.ts +13 -4
  26. package/dist/runtime/src/topics/lifecycle.ts +3 -2
  27. package/dist/runtime/src/types/api.ts +6 -0
  28. package/dist/runtime/src/version.ts +1 -1
  29. package/dist/storage.js +12 -6
  30. package/dist/storage.js.map +3 -3
  31. package/dist/types/packages/core/src/agents/archiver.d.ts +1 -1
  32. package/dist/types/packages/core/src/mcp/wiki-search-index.d.ts +59 -0
  33. package/dist/types/packages/core/src/mcp/wiki-server.d.ts +4 -0
  34. package/dist/types/packages/core/src/prompts/builders.d.ts +2 -4
  35. package/dist/types/packages/core/src/storage/api-topics.d.ts +1 -0
  36. package/dist/types/packages/core/src/types/api.d.ts +6 -0
  37. package/dist/types/packages/core/src/version.d.ts +1 -1
  38. package/package.json +1 -1
@@ -473,7 +473,8 @@ const MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {
473
473
  : null;
474
474
  }
475
475
  const args = [`--user-id=${userId}`];
476
- // Prefer the REST topic id so save_wiki_entry writes filenames and SQLite
476
+ if (topicId) args.push(`--current-topic-id=${topicId}`);
477
+ // Prefer the REST topic id so wiki_write writes filenames and SQLite
477
478
  // briefs keyed the same way the topic wiki API reads them.
478
479
  const resolvedWikiTopicId =
479
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,62 +46,70 @@ 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).
47
53
  - `topic` = the session name from the prompt (e.g. session `"dev"` → topic is `dev`)
54
+ - Before any wiki write, call `mcp__wiki__wiki_query(question=topic, kind="topic", limit=5)` and
55
+ inspect plausible candidates with `mcp__wiki__wiki_read(kind="topic", key=<candidate>)`.
56
+ - Set `canonical_topic` to an existing candidate only when it is clearly the same continuing
57
+ topic/persona. In that case, read it again with `adopt=true` so an active room keeps using that
58
+ key. If no candidate fits, set `canonical_topic = topic` and create a new topic memory.
59
+ - Do not merge memories from weak name overlap alone. Use the candidate description and brief;
60
+ when still uncertain, keep the current topic as a separate new memory.
61
+ - Use `canonical_topic` for every summary, brief, and topic-index write below. This keeps a
62
+ differently named room's summary and persona update in one canonical namespace.
48
63
  - If `sent_files:` is in the prompt, include those entries under `## Files Sent`
49
- - 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.
50
65
 
51
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.
52
67
 
53
- 3. **Save the immutable session summary** via `mcp__wiki__save_wiki_entry(topic, content)`.
54
- The MCP handles file naming + dedup → returns the saved path (e.g. `wiki/summaries/2026-05-08-dev.md`).
55
- It also records `latest_summary_md` + `summary_date` in SQLite (it does **not** touch the brief —
56
- 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.
57
73
  4. **Update articles** — for each genuinely reusable concept/decision/tool/pattern:
58
- - Glob existing articles: `Glob(wiki/articles/*.md)`
59
- - 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.
60
77
  - **Preserve frontmatter `date:` (first-seen) and `status:`.** Only refresh `updated:`.
61
78
  - **Preserve manually written body sections.** Only append/update what the session adds.
62
- - 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`).
63
83
  - Skip session-specific noise. If nothing qualifies, no articles change — that's fine.
64
- 5. **Update the accumulated persona brief last** via `mcp__wiki__save_topic_brief(topic, content)`.
84
+ 5. **Update the accumulated persona brief last** via
85
+ `mcp__wiki__wiki_write(kind="topic", topic=canonical_topic, content, description)`.
65
86
  This is the culmination of the run — the brief is not a worklog, it is the wiki's evolving
66
87
  **persona/user-model** for this topic: who the user is, how they want to be served, and where
67
88
  things stand. It is injected verbatim at the next session's start, so write it as durable memory,
68
89
  not as session notes.
69
- - **Read the existing brief first** (`Read wiki/topic/<topic>.md`) if present.
90
+ - **Read the existing brief first** with
91
+ `mcp__wiki__wiki_read(kind="topic", key=canonical_topic)` if present.
70
92
  - **Merge, don't overwrite.** Fold this session's Preferences / Patterns / durable Facts /
71
93
  Decisions into the **persona layer** (accumulate — the user-model is slow-moving). Refresh only
72
94
  the volatile layers (`## Recent Work`, `## Current State`) from this session. Preserve still-valid
73
95
  prior persona traits; remove a trait only when the new session explicitly supersedes it.
74
- - Keep one canonical file per topic title — `save_topic_brief` handles the path + SQLite mirror.
75
- Never add a UUID or room id. Write a fresh compact brief using the **brief format** below.
76
- 6. **Update the dual indexes via `mcp__wiki__index_upsert` — one call per entry.**
77
- The MCP handles in-place updates, section insertion, and the `created` vs `updated` date split. Do **not** Read/Write the index files manually.
78
-
79
- **For each new or updated article** (from step 4):
80
- - 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`.
81
- - Call: `index_upsert(slug=<article-slug>, description=<one-line>, kind="article", section=<chosen-header-without-"## ">)`
82
- - The MCP preserves the original `created` date on update — do not pass it.
83
-
84
- **For the new session summary** (from step 3):
85
- - Call: `index_upsert(slug=<summary-slug>, description=<one-line>, kind="summary")`
86
- - 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:
87
101
 
88
- **For this session's topic brief** (from step 5):
89
- - Call: `index_upsert(slug=<topic>, description=<one-line summary of recent work>, kind="topic")`
90
- - Pass the bare topic name (no `topic/` prefix); the MCP wikilinks it as `[[topic/<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.
91
107
 
92
- **Never delete entries** — `index_upsert` is insert-or-update only; pruning is a `wiki lint` concern.
93
-
94
- ## Section rules (recap, used when calling `index_upsert(kind="article", section=...)`)
108
+ ## Section rules (recap, used for `wiki_write(kind="article", section=...)`)
95
109
 
96
110
  1. Scan existing `## ...` headers in `article-index.md` first. Pick the closest match.
97
- 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`.
98
- 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.
99
113
 
100
114
  ## wiki/summaries/ entry format
101
115
 
@@ -216,9 +230,21 @@ _Last updated: {YYYY-MM-DD}_
216
230
 
217
231
  ## {Domain section 2}
218
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}_
219
245
 
220
- ## Source Summaries
221
- - [[2026-05-08-topic]] — desc (2026-05-08)
246
+ ## Session Summaries
247
+ - [[summaries/2026-05-08-topic]] — desc (2026-05-08)
222
248
  - ...
223
249
  ```
224
250
 
@@ -292,7 +318,6 @@ description: "keyword1, keyword2, likely trigger phrases the user would type —
292
318
  Summarize in `output_language` (report in pipeline order — summary → articles → brief):
293
319
  - 📝 summary: saved `wiki/summaries/<filename>`
294
320
  - 📄 articles: created/updated N pages (slugs)
295
- - 🗂 brief: `wiki/topic/<topic>.md` persona merge (`save_topic_brief`)
296
- - 📇 article-index: N `index_upsert` calls (article + summary)
297
- - 📇 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`)
298
323
  - 🛠 skill: created/updated `wiki/skills/<name>/skill.md` (or 'none')
@@ -587,46 +587,29 @@ export const buildChannelSystemPrompt = defaultPromptBuilders.buildChannelSystem
587
587
  export const buildManagerSystemPrompt = defaultPromptBuilders.buildManagerSystemPrompt;
588
588
 
589
589
  export function buildMemoryPromptSection(opts: {
590
- briefFile: string;
591
- wikiDir: string;
592
- hasFiles: boolean;
593
- latestSummaryFile?: string | null;
590
+ topicTitle: string;
591
+ memoryKey?: string;
594
592
  hasArchive?: boolean;
595
593
  isManager: boolean;
596
594
  }): string {
597
595
  const parts: string[] = ["\n\n## Memory"];
598
- parts.push(`Memory directory: ${opts.wikiDir}/topic`);
599
- parts.push(`Files: ${opts.hasFiles ? opts.briefFile : "(none)"}`);
600
- if (opts.latestSummaryFile) {
601
- parts.push(`Latest summary: ${opts.latestSummaryFile}`);
596
+ if (opts.isManager) {
597
+ parts.push(
598
+ "Use `wiki_query` for past workspace decisions or cross-topic context, then `wiki_read` only for the relevant result.",
599
+ );
600
+ } else if (opts.memoryKey) {
601
+ parts.push(
602
+ `This topic uses the canonical memory key \`${opts.memoryKey}\`. At the start of a new session, read it with \`wiki_read(kind: "topic", key: "${opts.memoryKey}")\`.`,
603
+ );
604
+ } else {
605
+ parts.push(
606
+ `At the start of a new session, search topic memories with \`wiki_query(question: "${opts.topicTitle}", kind: "topic", limit: 5)\` and read the most relevant candidate with \`wiki_read\`.`,
607
+ "If a candidate is clearly the same continuing topic/persona, read it with `adopt: true`; otherwise keep this topic's own name as its memory key. Do not merge weak or ambiguous matches.",
608
+ );
602
609
  }
603
610
  parts.push(
604
- "",
605
- opts.isManager
606
- ? opts.hasFiles
607
- ? "Above is this workspace's memory-hub brief (accumulated across all archived topics). For past context or other topics, consult it first, then use the `wiki_query` MCP tool for deeper recall."
608
- : "If you need past context while handling delegated work, use the `wiki_query` MCP tool."
609
- : opts.hasFiles
610
- ? "The file above is this topic's wiki brief. Read it first for past context; use the `wiki_query` MCP tool for deeper recall."
611
- : opts.latestSummaryFile
612
- ? "There is no wiki brief file yet. For past context, read the Latest summary first; use the `wiki_query` MCP tool for deeper recall."
613
- : "There is no wiki brief file yet. Use the `wiki_query` MCP tool for past context.",
614
- );
615
- parts.push(
616
- "",
617
- opts.isManager
618
- ? "When answering about past workspace decisions or other topics, weave in memory naturally, but confirm with `wiki_query` when unsure."
619
- : opts.hasFiles || opts.latestSummaryFile
620
- ? "On your first reply in the topic, read the wiki brief" +
621
- (opts.latestSummaryFile ? " and Latest summary" : "") +
622
- " and mention a one-line context recap so the user can quickly confirm it is on track."
623
- : "On your first reply in the topic, check prior conversation context if needed and mention it in one natural line.",
624
- );
625
- parts.push(
626
- "",
627
- opts.hasFiles
628
- ? "If the user says your memory is wrong, find the file in the Memory directory with Read and fix it directly with Edit."
629
- : "If the user says your memory is wrong, check the relevant wiki brief and fix it directly when possible, or use `wiki_query` to find the entry and reconcile.",
611
+ "Keep the lookup quiet. Mention prior context naturally in one short line only when it helps the user.",
612
+ 'Use `wiki_query(kind: "article")` for reusable knowledge and `wiki_query(kind: "summary")` for historical session details.',
630
613
  );
631
614
  if (opts.hasArchive) {
632
615
  parts.push(
@@ -1379,33 +1379,30 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1379
1379
  ? buildChannelSystemPrompt(systemPromptOpts)
1380
1380
  : buildTopicSystemPrompt(systemPromptOpts);
1381
1381
  const memoryTopic =
1382
- isMentionOnlyChannel || isManager ? topic : (getTopicMemoryOrigin(topicId) ?? topic);
1382
+ isMentionOnlyChannel || isManager || topic.memoryKey
1383
+ ? topic
1384
+ : (getTopicMemoryOrigin(topicId) ?? topic);
1385
+ const memoryTitle = memoryTopic.memoryKey?.trim() || memoryTopic.title;
1383
1386
  if (!isMentionOnlyChannel) {
1387
+ let hasMemoryArchive = false;
1384
1388
  try {
1385
- const resolvedBrief = resolveTopicBrief(memoryTopic.id, memoryTopic.title);
1389
+ const resolvedBrief = resolveTopicBrief(memoryTopic.id, memoryTitle);
1386
1390
  // #General is the workspace memory hub: its brief is the rolling digest the
1387
1391
  // archiver accumulates across ALL archived topics, and its files live in the
1388
1392
  // SHARED wiki root (getSharedWikiDir), not this topic's per-room workspace.
1389
1393
  const wikiDir = getSharedWikiDir();
1390
- const { briefFile, hasBriefFile, latestSummaryFile } = resolveWikiMemoryMirror(
1391
- wikiDir,
1392
- memoryTopic.id,
1393
- memoryTopic.title,
1394
- );
1395
- if (resolvedBrief || hasBriefFile || latestSummaryFile) {
1396
- systemPrompt += buildMemoryPromptSection({
1397
- briefFile,
1398
- wikiDir,
1399
- hasFiles: hasBriefFile,
1400
- latestSummaryFile,
1401
- hasArchive: Boolean(resolvedBrief?.brief.latestSummaryMd || latestSummaryFile),
1402
- isManager,
1403
- });
1404
- }
1394
+ const { latestSummaryFile } = resolveWikiMemoryMirror(wikiDir, memoryTopic.id, memoryTitle);
1395
+ hasMemoryArchive = Boolean(resolvedBrief?.brief.latestSummaryMd || latestSummaryFile);
1405
1396
  } catch (err) {
1406
1397
  // Brief fetch failure is non-fatal — don't block the turn.
1407
1398
  logger.warn({ topicId, err }, "ai: failed to inject topic brief");
1408
1399
  }
1400
+ systemPrompt += buildMemoryPromptSection({
1401
+ topicTitle: topic.title,
1402
+ ...(memoryTopic.memoryKey ? { memoryKey: memoryTopic.memoryKey } : {}),
1403
+ hasArchive: hasMemoryArchive,
1404
+ isManager,
1405
+ });
1409
1406
  }
1410
1407
  const activeVisual = visualTools ? getActiveVisualForPrompt(topicId, userId) : null;
1411
1408
  if (activeVisual) {
@@ -215,6 +215,7 @@ function initializeApiTopicsSchema(): void {
215
215
  last_message_at TEXT,
216
216
  parent_topic_id TEXT,
217
217
  memory_topic_id TEXT,
218
+ memory_key TEXT,
218
219
  is_fork INTEGER NOT NULL DEFAULT 0 CHECK (is_fork IN (0,1)),
219
220
  is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),
220
221
  visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),
@@ -287,8 +288,8 @@ function initializeApiTopicsSchema(): void {
287
288
  db.query(
288
289
  `INSERT INTO api_topics_next
289
290
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
290
- created_at,last_message_at,parent_topic_id,memory_topic_id,is_fork,is_subagent,visibility,access_mode,session_id)
291
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
291
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,access_mode,session_id)
292
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
292
293
  ).run(
293
294
  String(row.id),
294
295
  String(row.title),
@@ -302,6 +303,7 @@ function initializeApiTopicsSchema(): void {
302
303
  typeof row.last_message_at === "string" ? row.last_message_at : null,
303
304
  typeof row.parent_topic_id === "string" ? row.parent_topic_id : null,
304
305
  typeof row.memory_topic_id === "string" ? row.memory_topic_id : null,
306
+ typeof row.memory_key === "string" ? row.memory_key : null,
305
307
  Number(row.is_fork ?? 0) !== 0 ? 1 : 0,
306
308
  Number(row.is_subagent ?? 0) !== 0 ? 1 : 0,
307
309
  row.visibility === "hidden" ? "hidden" : "visible",
@@ -386,6 +388,9 @@ function initializeApiTopicsSchema(): void {
386
388
  if (!tableColumns("api_topics").has("memory_topic_id")) {
387
389
  db.exec("ALTER TABLE api_topics ADD COLUMN memory_topic_id TEXT");
388
390
  }
391
+ if (!tableColumns("api_topics").has("memory_key")) {
392
+ db.exec("ALTER TABLE api_topics ADD COLUMN memory_key TEXT");
393
+ }
389
394
  db.exec(`
390
395
  UPDATE api_topics
391
396
  SET browser_profile_owner = (
@@ -416,6 +421,7 @@ export interface TopicRow {
416
421
  last_message_at: string | null;
417
422
  parent_topic_id: string | null;
418
423
  memory_topic_id: string | null;
424
+ memory_key: string | null;
419
425
  is_fork: number;
420
426
  is_subagent: number;
421
427
  subagent_report_mode: string | null;
@@ -500,6 +506,7 @@ function rowToDto(
500
506
  lastMessageAt: r.last_message_at ?? new Date().toISOString(),
501
507
  parentTopicId: r.parent_topic_id ?? undefined,
502
508
  memoryTopicId: r.memory_topic_id ?? undefined,
509
+ memoryKey: r.memory_key ?? undefined,
503
510
  isFork: r.is_fork !== 0,
504
511
  ...(r.is_subagent !== 0 ? { isSubagent: true } : {}),
505
512
  ...(r.is_subagent !== 0
@@ -636,9 +643,9 @@ export function upsertTopic(t: TopicDto): void {
636
643
  db.query(
637
644
  `INSERT INTO api_topics
638
645
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
639
- created_at,last_message_at,parent_topic_id,memory_topic_id,is_fork,is_subagent,visibility,access_mode,
646
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,access_mode,
640
647
  subagent_report_mode)
641
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
648
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
642
649
  ON CONFLICT(id) DO UPDATE SET
643
650
  title = excluded.title,
644
651
  kind = excluded.kind,
@@ -651,6 +658,7 @@ export function upsertTopic(t: TopicDto): void {
651
658
  last_message_at = excluded.last_message_at,
652
659
  parent_topic_id = excluded.parent_topic_id,
653
660
  memory_topic_id = excluded.memory_topic_id,
661
+ memory_key = excluded.memory_key,
654
662
  is_fork = excluded.is_fork,
655
663
  is_subagent = excluded.is_subagent,
656
664
  visibility = excluded.visibility,
@@ -669,6 +677,7 @@ export function upsertTopic(t: TopicDto): void {
669
677
  t.lastMessageAt ?? null,
670
678
  t.parentTopicId ?? null,
671
679
  t.memoryTopicId ?? null,
680
+ t.memoryKey ?? null,
672
681
  t.isFork ? 1 : 0,
673
682
  t.isSubagent ? 1 : 0,
674
683
  normalizeTopicVisibility(t.visibility),
@@ -250,7 +250,8 @@ async function deleteTopicCascadeImpl(
250
250
  )
251
251
  ) {
252
252
  try {
253
- const memoryTopic = getTopicMemoryOrigin(topicId) ?? topic;
253
+ const memoryTopic = topic.memoryKey ? topic : (getTopicMemoryOrigin(topicId) ?? topic);
254
+ const memoryTitle = memoryTopic.memoryKey?.trim() || memoryTopic.title;
254
255
  runArchiverTurn({
255
256
  userId,
256
257
  // A memory origin that is also disappearing in this cascade must use
@@ -259,7 +260,7 @@ async function deleteTopicCascadeImpl(
259
260
  ...(memoryTopic.id !== topicId && !deletingTopicIds.has(memoryTopic.id)
260
261
  ? { topicId: memoryTopic.id }
261
262
  : {}),
262
- topicTitle: memoryTopic.title,
263
+ topicTitle: memoryTitle,
263
264
  archivePath: archived.path,
264
265
  ...(rawArchives.length > 0 ? { rawArchivePaths: rawArchives } : {}),
265
266
  messageCount: archived.messageCount,
@@ -108,6 +108,12 @@ export interface TopicDto {
108
108
  * When omitted, derived topics inherit memory through their parent chain.
109
109
  */
110
110
  memoryTopicId?: string;
111
+ /**
112
+ * Canonical title-keyed wiki memory namespace for this topic.
113
+ * Unlike memoryTopicId this survives room deletion/recreation and does not
114
+ * identify another live topic.
115
+ */
116
+ memoryKey?: string;
111
117
  /** True when the topic was created via fork (copies history). */
112
118
  isFork?: boolean;
113
119
  /** True when this topic was spawned by an agent as a subagent worker room. */
@@ -1 +1 @@
1
- export const NEGOTIUM_VERSION = "0.2.17";
1
+ export const NEGOTIUM_VERSION = "0.2.19";
package/dist/storage.js CHANGED
@@ -1339,6 +1339,7 @@ function initializeApiTopicsSchema() {
1339
1339
  last_message_at TEXT,
1340
1340
  parent_topic_id TEXT,
1341
1341
  memory_topic_id TEXT,
1342
+ memory_key TEXT,
1342
1343
  is_fork INTEGER NOT NULL DEFAULT 0 CHECK (is_fork IN (0,1)),
1343
1344
  is_subagent INTEGER NOT NULL DEFAULT 0 CHECK (is_subagent IN (0,1)),
1344
1345
  visibility TEXT NOT NULL DEFAULT 'visible' CHECK (visibility IN ('visible','hidden')),
@@ -1388,8 +1389,8 @@ function initializeApiTopicsSchema() {
1388
1389
  const legacyBaseEffort = row.base_effort ?? row.default_effort;
1389
1390
  db.query(`INSERT INTO api_topics_next
1390
1391
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
1391
- created_at,last_message_at,parent_topic_id,memory_topic_id,is_fork,is_subagent,visibility,access_mode,session_id)
1392
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(String(row.id), String(row.title), normalized.kind, typeof row.description === "string" ? row.description : null, normalized.agent ?? null, typeof legacyBaseModel === "string" ? legacyBaseModel : null, typeof legacyBaseEffort === "string" ? legacyBaseEffort : null, normalized.aiMode, String(row.created_at), typeof row.last_message_at === "string" ? row.last_message_at : null, typeof row.parent_topic_id === "string" ? row.parent_topic_id : null, typeof row.memory_topic_id === "string" ? row.memory_topic_id : null, Number(row.is_fork ?? 0) !== 0 ? 1 : 0, Number(row.is_subagent ?? 0) !== 0 ? 1 : 0, row.visibility === "hidden" ? "hidden" : "visible", row.access_mode === "shared" ? "shared" : "private", typeof row.session_id === "string" ? row.session_id : null);
1392
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,access_mode,session_id)
1393
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(String(row.id), String(row.title), normalized.kind, typeof row.description === "string" ? row.description : null, normalized.agent ?? null, typeof legacyBaseModel === "string" ? legacyBaseModel : null, typeof legacyBaseEffort === "string" ? legacyBaseEffort : null, normalized.aiMode, String(row.created_at), typeof row.last_message_at === "string" ? row.last_message_at : null, typeof row.parent_topic_id === "string" ? row.parent_topic_id : null, typeof row.memory_topic_id === "string" ? row.memory_topic_id : null, typeof row.memory_key === "string" ? row.memory_key : null, Number(row.is_fork ?? 0) !== 0 ? 1 : 0, Number(row.is_subagent ?? 0) !== 0 ? 1 : 0, row.visibility === "hidden" ? "hidden" : "visible", row.access_mode === "shared" ? "shared" : "private", typeof row.session_id === "string" ? row.session_id : null);
1393
1394
  }
1394
1395
  db.exec("DROP TABLE IF EXISTS topic_members");
1395
1396
  db.exec("DROP TABLE api_topics");
@@ -1458,6 +1459,9 @@ function initializeApiTopicsSchema() {
1458
1459
  if (!tableColumns2("api_topics").has("memory_topic_id")) {
1459
1460
  db.exec("ALTER TABLE api_topics ADD COLUMN memory_topic_id TEXT");
1460
1461
  }
1462
+ if (!tableColumns2("api_topics").has("memory_key")) {
1463
+ db.exec("ALTER TABLE api_topics ADD COLUMN memory_key TEXT");
1464
+ }
1461
1465
  db.exec(`
1462
1466
  UPDATE api_topics
1463
1467
  SET browser_profile_owner = (
@@ -1519,6 +1523,7 @@ function rowToDto2(r, participants = getTopicParticipants(r.id), tellTargets) {
1519
1523
  lastMessageAt: r.last_message_at ?? new Date().toISOString(),
1520
1524
  parentTopicId: r.parent_topic_id ?? undefined,
1521
1525
  memoryTopicId: r.memory_topic_id ?? undefined,
1526
+ memoryKey: r.memory_key ?? undefined,
1522
1527
  isFork: r.is_fork !== 0,
1523
1528
  ...r.is_subagent !== 0 ? { isSubagent: true } : {},
1524
1529
  ...r.is_subagent !== 0 ? {
@@ -1613,9 +1618,9 @@ function upsertTopic(t) {
1613
1618
  db.transaction(() => {
1614
1619
  db.query(`INSERT INTO api_topics
1615
1620
  (id,title,kind,description,agent,base_model,base_effort,response_policy,
1616
- created_at,last_message_at,parent_topic_id,memory_topic_id,is_fork,is_subagent,visibility,access_mode,
1621
+ created_at,last_message_at,parent_topic_id,memory_topic_id,memory_key,is_fork,is_subagent,visibility,access_mode,
1617
1622
  subagent_report_mode)
1618
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1623
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1619
1624
  ON CONFLICT(id) DO UPDATE SET
1620
1625
  title = excluded.title,
1621
1626
  kind = excluded.kind,
@@ -1628,11 +1633,12 @@ function upsertTopic(t) {
1628
1633
  last_message_at = excluded.last_message_at,
1629
1634
  parent_topic_id = excluded.parent_topic_id,
1630
1635
  memory_topic_id = excluded.memory_topic_id,
1636
+ memory_key = excluded.memory_key,
1631
1637
  is_fork = excluded.is_fork,
1632
1638
  is_subagent = excluded.is_subagent,
1633
1639
  visibility = excluded.visibility,
1634
1640
  access_mode = excluded.access_mode,
1635
- subagent_report_mode = excluded.subagent_report_mode`).run(t.id, t.title, normalized.kind, t.description ?? null, normalized.agent ?? null, t.defaultModel ?? null, t.defaultEffort ?? null, normalized.aiMode, t.createdAt, t.lastMessageAt ?? null, t.parentTopicId ?? null, t.memoryTopicId ?? null, t.isFork ? 1 : 0, t.isSubagent ? 1 : 0, normalizeTopicVisibility(t.visibility), normalizeTopicAccessMode(t.accessMode), t.subagentReportMode ?? "auto");
1641
+ subagent_report_mode = excluded.subagent_report_mode`).run(t.id, t.title, normalized.kind, t.description ?? null, normalized.agent ?? null, t.defaultModel ?? null, t.defaultEffort ?? null, normalized.aiMode, t.createdAt, t.lastMessageAt ?? null, t.parentTopicId ?? null, t.memoryTopicId ?? null, t.memoryKey ?? null, t.isFork ? 1 : 0, t.isSubagent ? 1 : 0, normalizeTopicVisibility(t.visibility), normalizeTopicAccessMode(t.accessMode), t.subagentReportMode ?? "auto");
1636
1642
  db.query("DELETE FROM topic_members WHERE topic_id = ?").run(t.id);
1637
1643
  for (const participant of t.participants) {
1638
1644
  db.query("INSERT INTO topic_members (topic_id,user_id,role) VALUES (?,?,?)").run(t.id, participant.userId, participant.role);
@@ -4166,4 +4172,4 @@ export {
4166
4172
  ConversationLogDivergedError
4167
4173
  };
4168
4174
 
4169
- //# debugId=0E9898BA8C097A7864756E2164756E21
4175
+ //# debugId=D0FC18F9A934EFB264756E2164756E21