@0xmaxma/claude-gateway 1.7.8 → 1.7.10

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 (125) hide show
  1. package/README.md +11 -3
  2. package/dist/agent/dreaming/accept.d.ts.map +1 -1
  3. package/dist/agent/dreaming/accept.js +4 -1
  4. package/dist/agent/dreaming/accept.js.map +1 -1
  5. package/dist/agent/dreaming/config.d.ts.map +1 -1
  6. package/dist/agent/dreaming/config.js +2 -0
  7. package/dist/agent/dreaming/config.js.map +1 -1
  8. package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
  9. package/dist/agent/dreaming/reviewer.js +9 -2
  10. package/dist/agent/dreaming/reviewer.js.map +1 -1
  11. package/dist/agent/dreaming/staleness.d.ts +7 -1
  12. package/dist/agent/dreaming/staleness.d.ts.map +1 -1
  13. package/dist/agent/dreaming/staleness.js +17 -2
  14. package/dist/agent/dreaming/staleness.js.map +1 -1
  15. package/dist/agent/dreaming/types.d.ts +17 -1
  16. package/dist/agent/dreaming/types.d.ts.map +1 -1
  17. package/dist/agent/knowledge/archive-db.d.ts +43 -0
  18. package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
  19. package/dist/agent/knowledge/archive-db.js +130 -0
  20. package/dist/agent/knowledge/archive-db.js.map +1 -1
  21. package/dist/agent/knowledge/config.d.ts +3 -1
  22. package/dist/agent/knowledge/config.d.ts.map +1 -1
  23. package/dist/agent/knowledge/config.js +30 -1
  24. package/dist/agent/knowledge/config.js.map +1 -1
  25. package/dist/agent/knowledge/index.d.ts +11 -4
  26. package/dist/agent/knowledge/index.d.ts.map +1 -1
  27. package/dist/agent/knowledge/index.js +22 -1
  28. package/dist/agent/knowledge/index.js.map +1 -1
  29. package/dist/agent/knowledge/indexer.d.ts +0 -7
  30. package/dist/agent/knowledge/indexer.d.ts.map +1 -1
  31. package/dist/agent/knowledge/indexer.js +34 -7
  32. package/dist/agent/knowledge/indexer.js.map +1 -1
  33. package/dist/agent/knowledge/lifecycle.d.ts +20 -4
  34. package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
  35. package/dist/agent/knowledge/lifecycle.js +33 -4
  36. package/dist/agent/knowledge/lifecycle.js.map +1 -1
  37. package/dist/agent/knowledge/reflection.d.ts +114 -0
  38. package/dist/agent/knowledge/reflection.d.ts.map +1 -0
  39. package/dist/agent/knowledge/reflection.js +447 -0
  40. package/dist/agent/knowledge/reflection.js.map +1 -0
  41. package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
  42. package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
  43. package/dist/agent/knowledge/shared-dedup.js +187 -0
  44. package/dist/agent/knowledge/shared-dedup.js.map +1 -0
  45. package/dist/agent/knowledge/shared-promote.d.ts +82 -7
  46. package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
  47. package/dist/agent/knowledge/shared-promote.js +244 -11
  48. package/dist/agent/knowledge/shared-promote.js.map +1 -1
  49. package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
  50. package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
  51. package/dist/agent/knowledge/shared-staleness.js +149 -0
  52. package/dist/agent/knowledge/shared-staleness.js.map +1 -0
  53. package/dist/agent/knowledge/shared-writer.d.ts +19 -0
  54. package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
  55. package/dist/agent/knowledge/shared-writer.js +42 -0
  56. package/dist/agent/knowledge/shared-writer.js.map +1 -1
  57. package/dist/agent/knowledge/types.d.ts +24 -0
  58. package/dist/agent/knowledge/types.d.ts.map +1 -1
  59. package/dist/agent/runner.d.ts +7 -0
  60. package/dist/agent/runner.d.ts.map +1 -1
  61. package/dist/agent/runner.js +106 -16
  62. package/dist/agent/runner.js.map +1 -1
  63. package/dist/agent/workspace-loader.d.ts +17 -2
  64. package/dist/agent/workspace-loader.d.ts.map +1 -1
  65. package/dist/agent/workspace-loader.js +17 -1
  66. package/dist/agent/workspace-loader.js.map +1 -1
  67. package/dist/api/gateway-router.d.ts.map +1 -1
  68. package/dist/api/gateway-router.js +2 -1
  69. package/dist/api/gateway-router.js.map +1 -1
  70. package/dist/api/line-webhook-router.d.ts.map +1 -1
  71. package/dist/api/line-webhook-router.js +7 -15
  72. package/dist/api/line-webhook-router.js.map +1 -1
  73. package/dist/api/slack-client.d.ts +25 -2
  74. package/dist/api/slack-client.d.ts.map +1 -1
  75. package/dist/api/slack-client.js +102 -0
  76. package/dist/api/slack-client.js.map +1 -1
  77. package/dist/api/slack-webhook-router.d.ts +15 -1
  78. package/dist/api/slack-webhook-router.d.ts.map +1 -1
  79. package/dist/api/slack-webhook-router.js +128 -1
  80. package/dist/api/slack-webhook-router.js.map +1 -1
  81. package/dist/apps/compose-generator.js +7 -4
  82. package/dist/apps/compose-generator.js.map +1 -1
  83. package/dist/apps/installer.d.ts +86 -0
  84. package/dist/apps/installer.d.ts.map +1 -1
  85. package/dist/apps/installer.js +440 -52
  86. package/dist/apps/installer.js.map +1 -1
  87. package/dist/index.js +37 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/session/compactor.d.ts +32 -2
  90. package/dist/session/compactor.d.ts.map +1 -1
  91. package/dist/session/compactor.js +134 -19
  92. package/dist/session/compactor.js.map +1 -1
  93. package/dist/session/process.d.ts +20 -3
  94. package/dist/session/process.d.ts.map +1 -1
  95. package/dist/session/process.js +120 -5
  96. package/dist/session/process.js.map +1 -1
  97. package/dist/shared/image-sniff.d.ts +15 -0
  98. package/dist/shared/image-sniff.d.ts.map +1 -0
  99. package/dist/shared/image-sniff.js +29 -0
  100. package/dist/shared/image-sniff.js.map +1 -0
  101. package/dist/shell/claude-pty-shell.js +146 -27
  102. package/dist/shell/claude-pty-shell.js.map +1 -1
  103. package/dist/shell/draft-phantom.d.ts +49 -0
  104. package/dist/shell/draft-phantom.d.ts.map +1 -0
  105. package/dist/shell/draft-phantom.js +59 -0
  106. package/dist/shell/draft-phantom.js.map +1 -0
  107. package/dist/shell/submit-diag.d.ts +67 -0
  108. package/dist/shell/submit-diag.d.ts.map +1 -0
  109. package/dist/shell/submit-diag.js +69 -0
  110. package/dist/shell/submit-diag.js.map +1 -0
  111. package/dist/types.d.ts +25 -0
  112. package/dist/types.d.ts.map +1 -1
  113. package/mcp/tools/discord/module.ts +24 -17
  114. package/mcp/tools/memory/archive-reader.test.ts +25 -1
  115. package/mcp/tools/memory/archive-reader.ts +70 -12
  116. package/mcp/tools/memory/archive-writer.test.ts +168 -0
  117. package/mcp/tools/memory/archive-writer.ts +137 -0
  118. package/mcp/tools/memory/module.test.ts +187 -0
  119. package/mcp/tools/memory/module.ts +192 -6
  120. package/mcp/tools/shared/limits.ts +14 -0
  121. package/mcp/tools/slack/module.ts +66 -4
  122. package/mcp/tools/telegram/module.ts +11 -3
  123. package/mcp/tools/telegram/receiver-server.ts +10 -3
  124. package/mcp/tools/telegram/typing.ts +178 -34
  125. package/package.json +1 -1
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Write-side glue for the shared KB (Bun/mcp side; planning-64 K3 follow-up).
3
+ *
4
+ * Unlike the read side (archive-reader.ts, which must reimplement its query
5
+ * path because `bun:sqlite`/`node:sqlite` are different bindings over the
6
+ * same file), the atomic-write logic itself (slugify + write-temp-then-rename)
7
+ * has NO `node:sqlite` dependency — it's plain `fs`/`path`. So this file does
8
+ * NOT duplicate it: it imports the real `writeSharedNote` / `sharedNoteFilename`
9
+ * straight from the compiled `dist/agent/knowledge/shared-writer.js`, the same
10
+ * pattern already used elsewhere in `mcp/` for logic it needs but cannot reach
11
+ * in `src/` directly (see `mcp/tools/slack/module.ts`'s `SlackClient` import,
12
+ * `mcp/tools/telegram/receiver-server.ts`'s `turn-trace`/`incident-store`
13
+ * imports). Verified against mcp-no-src-imports.test.ts.
14
+ */
15
+
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { spawn } from 'child_process';
19
+ import { writeSharedNote, sharedNoteFilename } from '../../../dist/agent/knowledge/shared-writer.js';
20
+ import type { ResolvedKnowledgeSharedCfg } from '../../../dist/agent/knowledge/types.js';
21
+
22
+ export { sharedNoteFilename };
23
+
24
+ /**
25
+ * Reconstruct the minimal resolved shared-KB config `writeSharedNote` needs
26
+ * (only `root`/`project` are actually read, via `sharedNotesDir`), by
27
+ * splitting `vaultDir` = `<root>/<project>` — the inverse of the Node-side
28
+ * `sharedVaultDir()`. `mode`/`graph`/`enabled` are dead weight for this call
29
+ * (writeSharedNote never reads them) but filled in for type shape.
30
+ */
31
+ function cfgFromVaultDir(vaultDir: string): ResolvedKnowledgeSharedCfg {
32
+ return {
33
+ enabled: true,
34
+ root: path.dirname(vaultDir),
35
+ project: path.basename(vaultDir),
36
+ mode: 'auto',
37
+ graph: false,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Note identity is the caller's own freeform `name`, slugified to one safe
43
+ * path segment (`sharedNoteFilename`) — deliberately NOT prefixed with an
44
+ * agent id or content-hashed. `memory_shared_create`/`_update`/`_delete` are
45
+ * a shared, cross-agent namespace by design: any agent can name a note
46
+ * anything, and a repeat call with the same name addresses the same file.
47
+ * This is why `memory_shared_create` cross-checks near-duplicates via
48
+ * `findSimilarSharedNotes` before writing — nothing else prevents two agents
49
+ * from independently picking the same topic under different names.
50
+ *
51
+ * This matches the nightly promoter's reason-derived naming (`shared-promote.ts`),
52
+ * so recurring automatic promotions and manual writes resolve through the same
53
+ * freeform shared namespace.
54
+ */
55
+
56
+ /** Whether a note already exists at this name (post-`sharedNoteFilename` slugification). */
57
+ export function sharedNoteExists(vaultDir: string, name: string): boolean {
58
+ return fs.existsSync(path.join(vaultDir, 'notes', sharedNoteFilename(name)));
59
+ }
60
+
61
+ /** Full content of a shared note, or null if it does not exist. */
62
+ export function readSharedNote(vaultDir: string, name: string): string | null {
63
+ const target = path.join(vaultDir, 'notes', sharedNoteFilename(name));
64
+ if (!fs.existsSync(target)) return null;
65
+ return fs.readFileSync(target, 'utf8');
66
+ }
67
+
68
+ /** Write a note into the shared vault atomically. Returns the absolute path written. */
69
+ export function writeSharedNoteAtomic(vaultDir: string, name: string, content: string): string {
70
+ return writeSharedNote(cfgFromVaultDir(vaultDir), name, content);
71
+ }
72
+
73
+ /** Delete a note by name. Returns false (no-op) if it did not exist. */
74
+ export function deleteSharedNote(vaultDir: string, name: string): boolean {
75
+ const target = path.join(vaultDir, 'notes', sharedNoteFilename(name));
76
+ if (!fs.existsSync(target)) return false;
77
+ fs.rmSync(target);
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * Percentage (0-100) of `oldContent`'s non-blank lines that do NOT appear
83
+ * verbatim anywhere in `newContent` — `memory_shared_update`'s guard against
84
+ * an agent blindly clobbering a note instead of editing it. Deliberately a
85
+ * blunt line-membership check (not a real diff/LCS): cheap, deterministic,
86
+ * and good enough to flag "this update discards most of what was here",
87
+ * which is all the guard needs to do. 0 old lines counts as 0% lost (nothing
88
+ * to lose) rather than dividing by zero.
89
+ */
90
+ export function contentLossPercent(oldContent: string, newContent: string): number {
91
+ const oldLines = oldContent.split('\n').map((l) => l.trim()).filter(Boolean);
92
+ if (oldLines.length === 0) return 0;
93
+ const newLineSet = new Set(newContent.split('\n').map((l) => l.trim()));
94
+ const lost = oldLines.filter((l) => !newLineSet.has(l)).length;
95
+ return Math.round((lost / oldLines.length) * 100);
96
+ }
97
+
98
+ /**
99
+ * Fire-and-forget reindex trigger so a promoted note is searchable immediately,
100
+ * instead of waiting for the next natural reindex (session spawn / nightly
101
+ * dream). Spawns the SAME compiled CLI the Node-side `spawnArchiveReindex`
102
+ * uses (`dist/agent/knowledge/reindex-cli.js`) as a SUBPROCESS (never
103
+ * imported — reindex-cli.ts requires `node:sqlite`, which Bun does not have,
104
+ * so it can only be run, not loaded, from this process).
105
+ *
106
+ * This code runs under Bun, whose own `process.execPath` points at the `bun`
107
+ * binary — spawning the CLI with that fails silently (`node:sqlite` is not a
108
+ * Bun built-in) since the child is detached with `stdio:'ignore'`. The gateway
109
+ * forwards its own (real Node) `process.execPath` via `GATEWAY_NODE_EXEC_PATH`
110
+ * (see src/session/process.ts) specifically so this spawn uses the right
111
+ * runtime; fall back to a bare `node` on PATH if that env is somehow unset.
112
+ *
113
+ * The CLI's shared-archive half only needs `{enabled, root, project}` (see
114
+ * `cfgFromVaultDir`); the personal-archive half is explicitly disabled
115
+ * (`{enabled:false}`) since this trigger only concerns the shared vault.
116
+ * Best-effort: swallows every failure so a promote call never fails because
117
+ * reindexing couldn't be kicked off.
118
+ */
119
+ export function triggerSharedReindex(vaultDir: string): void {
120
+ try {
121
+ const cliPath = path.join(__dirname, '..', '..', '..', 'dist', 'agent', 'knowledge', 'reindex-cli.js');
122
+ if (!fs.existsSync(cliPath)) return;
123
+ const nodeBin = process.env.GATEWAY_NODE_EXEC_PATH || 'node';
124
+ const sharedCfg = cfgFromVaultDir(vaultDir);
125
+ const child = spawn(
126
+ nodeBin,
127
+ [cliPath, vaultDir, JSON.stringify({ enabled: false }), JSON.stringify(sharedCfg)],
128
+ { detached: true, stdio: 'ignore' },
129
+ );
130
+ child.on('error', () => {
131
+ /* swallowed */
132
+ });
133
+ child.unref();
134
+ } catch {
135
+ /* best-effort: reindex is never on the critical path of a promote call */
136
+ }
137
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Bun tests for MemoryModule.handleTool's memory_shared_create / _get / _update /
3
+ * _delete — the exists/missing gates, the near-duplicate nudge on create, and the
4
+ * content-loss confirmation guard on update.
5
+ */
6
+
7
+ import { test, expect, beforeEach, afterEach } from 'bun:test';
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+
12
+ import { MemoryModule } from './module';
13
+
14
+ let workspaceDir: string;
15
+ let vaultDir: string;
16
+ const originalEnv = {
17
+ GATEWAY_WORKSPACE_DIR: process.env.GATEWAY_WORKSPACE_DIR,
18
+ GATEWAY_SHARED_KB_DIR: process.env.GATEWAY_SHARED_KB_DIR,
19
+ };
20
+
21
+ beforeEach(() => {
22
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-module-ws-'));
23
+ vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-module-shared-'));
24
+ process.env.GATEWAY_WORKSPACE_DIR = workspaceDir;
25
+ process.env.GATEWAY_SHARED_KB_DIR = vaultDir;
26
+ });
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(workspaceDir, { recursive: true, force: true });
30
+ fs.rmSync(vaultDir, { recursive: true, force: true });
31
+ for (const [k, v] of Object.entries(originalEnv)) {
32
+ if (v === undefined) delete process.env[k];
33
+ else process.env[k] = v;
34
+ }
35
+ });
36
+
37
+ function textOf(result: Awaited<ReturnType<MemoryModule['handleTool']>>): string {
38
+ return (result.content[0] as { text: string }).text;
39
+ }
40
+
41
+ test('memory_shared_create: writes a new note under a freeform name (no agent-id prefix)', async () => {
42
+ const mod = new MemoryModule();
43
+ const result = await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
44
+ expect(result.isError).toBeUndefined();
45
+ const body = JSON.parse(textOf(result));
46
+ expect(body.created).toBe(true);
47
+ expect(fs.existsSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'))).toBe(true);
48
+ });
49
+
50
+ test('memory_shared_create: fails with an exact-name collision, does not overwrite', async () => {
51
+ const mod = new MemoryModule();
52
+ await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'v1: escalate to platform team' });
53
+ const result = await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'v2 attempt' });
54
+ expect(result.isError).toBe(true);
55
+ expect(textOf(result)).toContain('already exists');
56
+ expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('v1: escalate to platform team');
57
+ });
58
+
59
+ test('memory_shared_create: any agent can create — no GATEWAY_AGENT_ID required', async () => {
60
+ delete process.env.GATEWAY_AGENT_ID;
61
+ const mod = new MemoryModule();
62
+ const result = await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
63
+ expect(result.isError).toBeUndefined();
64
+ });
65
+
66
+ test('memory_shared_get: reads full content; errors on a missing note', async () => {
67
+ const mod = new MemoryModule();
68
+ const missing = await mod.handleTool('memory_shared_get', { name: 'oncall-runbook' });
69
+ expect(missing.isError).toBe(true);
70
+
71
+ await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
72
+ const result = await mod.handleTool('memory_shared_get', { name: 'oncall-runbook' });
73
+ expect(result.isError).toBeUndefined();
74
+ const body = JSON.parse(textOf(result));
75
+ expect(body.content).toBe('escalate to SRE');
76
+ });
77
+
78
+ test('memory_shared_update: fails when the note does not exist yet, pointing at memory_shared_create', async () => {
79
+ const mod = new MemoryModule();
80
+ const result = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'escalate to SRE' });
81
+ expect(result.isError).toBe(true);
82
+ expect(textOf(result)).toContain('memory_shared_create');
83
+ });
84
+
85
+ test('memory_shared_update: applies a small edit (below the loss threshold) without confirm', async () => {
86
+ const mod = new MemoryModule();
87
+ await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'line one\nline two\nline three\nline four' });
88
+ // Drop 1 of 4 lines (25% loss) — under the 50% threshold, should apply directly.
89
+ const result = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'line one\nline two\nline three' });
90
+ expect(result.isError).toBeUndefined();
91
+ const body = JSON.parse(textOf(result));
92
+ expect(body.updated).toBe(true);
93
+ expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('line one\nline two\nline three');
94
+ });
95
+
96
+ test('memory_shared_update: a large content loss needs confirm:true, and does not write until given', async () => {
97
+ const mod = new MemoryModule();
98
+ await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'line one\nline two\nline three\nline four' });
99
+
100
+ const warned = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'brand new unrelated content' });
101
+ expect(warned.isError).toBeUndefined();
102
+ const warnedBody = JSON.parse(textOf(warned));
103
+ expect(warnedBody.updated).toBe(false);
104
+ expect(warnedBody.needsConfirmation).toBe(true);
105
+ expect(warnedBody.lossPercent).toBe(100);
106
+ // Still the original content — the warning must not have written anything.
107
+ expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('line one\nline two\nline three\nline four');
108
+
109
+ const confirmed = await mod.handleTool('memory_shared_update', { name: 'oncall-runbook', content: 'brand new unrelated content', confirm: true });
110
+ const confirmedBody = JSON.parse(textOf(confirmed));
111
+ expect(confirmedBody.updated).toBe(true);
112
+ expect(fs.readFileSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'), 'utf8')).toBe('brand new unrelated content');
113
+ });
114
+
115
+ test('memory_shared_delete: any agent can delete any note — no ownership scoping', async () => {
116
+ const mod = new MemoryModule();
117
+ await mod.handleTool('memory_shared_create', { name: 'oncall-runbook', content: 'escalate to SRE' });
118
+ delete process.env.GATEWAY_AGENT_ID; // a different/misconfigured agent — still allowed
119
+ const result = await mod.handleTool('memory_shared_delete', { name: 'oncall-runbook' });
120
+ expect(result.isError).toBeUndefined();
121
+ expect(fs.existsSync(path.join(vaultDir, 'notes', 'oncall-runbook.md'))).toBe(false);
122
+ });
123
+
124
+ test('memory_shared_delete: errors (no-op) on a name that does not exist', async () => {
125
+ const mod = new MemoryModule();
126
+ const result = await mod.handleTool('memory_shared_delete', { name: 'never-created' });
127
+ expect(result.isError).toBe(true);
128
+ });
129
+
130
+ // End-to-end proof that memory_shared_create's near-duplicate nudge actually
131
+ // fires through the real reindex path (not just the pure findSimilarSharedNotes
132
+ // unit tests in archive-reader.test.ts). Skips gracefully if dist/ hasn't been
133
+ // built, same guard as archive-writer.test.ts's e2e reindex test.
134
+ test('memory_shared_create: near-duplicate content is nudged for confirmation once reindexed', async () => {
135
+ const cliPath = path.join(__dirname, '..', '..', '..', 'dist', 'agent', 'knowledge', 'reindex-cli.js');
136
+ if (!fs.existsSync(cliPath)) {
137
+ console.warn('[memory module.test] dist/agent/knowledge/reindex-cli.js missing — run `npm run build` first; skipping e2e near-dup test');
138
+ return;
139
+ }
140
+ const mod = new MemoryModule();
141
+ await mod.handleTool('memory_shared_create', {
142
+ name: 'oncall-escalation-runbook',
143
+ content: 'escalate paging incidents to the platform team via pagerduty, then page SRE if unacknowledged in 10 minutes',
144
+ });
145
+
146
+ // Wait for the fire-and-forget reindex triggered by the create above.
147
+ const dbPath = path.join(vaultDir, 'kb.sqlite');
148
+ const deadline = Date.now() + 8000;
149
+ while (Date.now() < deadline && !fs.existsSync(dbPath)) {
150
+ await new Promise((r) => setTimeout(r, 150));
151
+ }
152
+
153
+ let result = await mod.handleTool('memory_shared_create', {
154
+ name: 'paging-oncall-playbook',
155
+ content: 'oncall playbook: paging escalation to platform team via pagerduty',
156
+ });
157
+ // Reindex is best-effort/async; retry briefly if it hadn't landed yet.
158
+ const retryDeadline = Date.now() + 8000;
159
+ while (Date.now() < retryDeadline) {
160
+ const body = JSON.parse(textOf(result));
161
+ if (body.needsConfirmation) break;
162
+ await new Promise((r) => setTimeout(r, 200));
163
+ result = await mod.handleTool('memory_shared_create', {
164
+ name: 'paging-oncall-playbook',
165
+ content: 'oncall playbook: paging escalation to platform team via pagerduty',
166
+ });
167
+ }
168
+ const body = JSON.parse(textOf(result));
169
+ expect(body.created).toBe(false);
170
+ expect(body.needsConfirmation).toBe(true);
171
+ expect(body.similar.some((s: { path: string }) => s.path.includes('oncall-escalation-runbook'))).toBe(true);
172
+ expect(fs.existsSync(path.join(vaultDir, 'notes', 'paging-oncall-playbook.md'))).toBe(false);
173
+
174
+ // confirm:true creates it anyway.
175
+ const confirmed = await mod.handleTool('memory_shared_create', {
176
+ name: 'paging-oncall-playbook',
177
+ content: 'oncall playbook: paging escalation to platform team via pagerduty',
178
+ confirm: true,
179
+ });
180
+ expect(JSON.parse(textOf(confirmed)).created).toBe(true);
181
+
182
+ // #386: confirming past a near-dup nudge should link the related note(s)
183
+ // found, not leave a disconnected duplicate — so /knowledge/graph gets a
184
+ // real edge instead of staying empty.
185
+ const written = fs.readFileSync(path.join(vaultDir, 'notes', 'paging-oncall-playbook.md'), 'utf8');
186
+ expect(written).toContain('[[oncall-escalation-runbook]]');
187
+ }, 20000);
@@ -10,11 +10,22 @@
10
10
  */
11
11
 
12
12
  import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
13
- import { searchArchive, getExcerpt, archiveDbPath, sharedDbPathFromEnv, mergeHits } from './archive-reader';
13
+ import { searchArchive, findSimilarSharedNotes, getExcerpt, archiveDbPath, sharedDbPathFromEnv, mergeHits, recordSharedPathRetrieval } from './archive-reader';
14
+ import { sharedNoteExists, readSharedNote, writeSharedNoteAtomic, deleteSharedNote, contentLossPercent, triggerSharedReindex, sharedNoteFilename } from './archive-writer';
15
+
16
+ /** memory_shared_update: below this line-loss %, an update just applies — no confirm needed. */
17
+ const UPDATE_LOSS_CONFIRM_THRESHOLD = 50;
14
18
 
15
19
  /** Corpora the tool can serve. */
16
20
  const SUPPORTED_CORPORA = new Set(['memory', 'shared', 'all']);
17
21
 
22
+ // Mirrors skill_create's MAX_SKILL_SIZE (mcp/tools/skills/handlers.ts) — the
23
+ // direct structural precedent for a write-capable, scope-aware MCP tool. A
24
+ // shared note lives in a vault SHARED across every agent in the project, so
25
+ // leaving it uncapped would let one agent (a runaway loop, or a prompt
26
+ // injection) exhaust shared disk / bloat the shared FTS5 index for everyone.
27
+ const MAX_SHARED_NOTE_SIZE = 100 * 1024; // 100KB
28
+
18
29
  export class MemoryModule implements ToolModule {
19
30
  id = 'memory';
20
31
  toolVisibility: ToolVisibility = 'all-configured';
@@ -64,6 +75,60 @@ export class MemoryModule implements ToolModule {
64
75
  additionalProperties: false,
65
76
  },
66
77
  },
78
+ {
79
+ name: 'memory_shared_create',
80
+ description:
81
+ 'Create a NEW note in the shared, cross-agent knowledge base RIGHT NOW, instead of waiting for the nightly automatic promotion. This is an explicit, agent-initiated write (works even when shared-KB mode is "propose"). Notes live in a shared namespace across every agent — pick any "name" you like, there is no agent-id prefix. Fails if a note with this exact name already exists (use memory_shared_update on it instead, after reading it with memory_shared_get). Before writing, this also searches the shared KB for notes with SIMILAR content and, if any are found, returns them instead of creating (to avoid near-duplicate notes cluttering the vault) — pass confirm:true to create anyway once you have checked they are not the same topic; when you do, a `[[link]]` to each similar note found is appended to your content automatically, so the knowledge-graph dashboard stays connected instead of gaining a disconnected duplicate. The note becomes searchable via memory_search (corpus "shared" or "all") shortly after this call returns.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ name: { type: 'string', description: 'Freeform name identifying this note, e.g. "deploy-runbook". Stable identity — reuse it with memory_shared_update to edit the same note later.' },
86
+ content: { type: 'string', description: 'Full note body to write into the shared KB. Max 100KB.' },
87
+ confirm: { type: 'boolean', description: 'Create anyway even though similar existing notes were found. Default: false.' },
88
+ },
89
+ required: ['name', 'content'],
90
+ additionalProperties: false,
91
+ },
92
+ },
93
+ {
94
+ name: 'memory_shared_get',
95
+ description: 'Read the full current content of one shared-KB note by its exact "name" (see memory_shared_create). Use this before memory_shared_update so your edit is based on what is actually there — memory_search only returns short snippets, not the full note.',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ name: { type: 'string', description: 'The note\'s exact name.' },
100
+ },
101
+ required: ['name'],
102
+ additionalProperties: false,
103
+ },
104
+ },
105
+ {
106
+ name: 'memory_shared_update',
107
+ description:
108
+ `Update an EXISTING shared-KB note by its exact "name" — replaces its full content with "content". Fails if no note with this name exists yet (use memory_shared_create instead). Read the note first with memory_shared_get and edit it — "content" is what the note becomes, not what gets appended, so anything you drop is gone. As a guard against an update that blindly discards most of the note (rather than an intentional rewrite), if the new content is missing ${UPDATE_LOSS_CONFIRM_THRESHOLD}%+ of the existing note's lines, this returns a warning instead of writing — pass confirm:true once you have checked that is what you intend.`,
109
+ inputSchema: {
110
+ type: 'object',
111
+ properties: {
112
+ name: { type: 'string', description: 'The note\'s exact name (see memory_shared_create).' },
113
+ content: { type: 'string', description: 'Full replacement body for the note. Max 100KB.' },
114
+ confirm: { type: 'boolean', description: `Apply the update even though it would remove ${UPDATE_LOSS_CONFIRM_THRESHOLD}%+ of the existing content. Default: false.` },
115
+ },
116
+ required: ['name', 'content'],
117
+ additionalProperties: false,
118
+ },
119
+ },
120
+ {
121
+ name: 'memory_shared_delete',
122
+ description: 'Delete one shared-KB note by its exact "name" (see memory_shared_create). Any agent can delete any note in the shared vault — there is no per-agent ownership. This cannot delete notes the nightly dreaming pipeline promoted (different naming scheme).',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {
126
+ name: { type: 'string', description: 'The note\'s exact name.' },
127
+ },
128
+ required: ['name'],
129
+ additionalProperties: false,
130
+ },
131
+ },
67
132
  ];
68
133
  }
69
134
 
@@ -94,22 +159,23 @@ export class MemoryModule implements ToolModule {
94
159
 
95
160
  const personalDb = archiveDbPath(workspaceDir);
96
161
  const sharedDb = sharedDbPathFromEnv();
97
- // Recall counter (planning-66): record retrievals ONLY against the
98
- // per-agent archive (the GC tier). Gated by the gateway via env, mirroring
99
- // GATEWAY_SHARED_KB_DIR — off unless dreaming.staleness.recordRetrievals.
162
+ // Recall counters are append-only, best-effort telemetry. Apply the
163
+ // same opt-in recording to BOTH KB tiers: shared lifecycle/staleness
164
+ // (issue #392) needs real `memory_search` reads to retain and restore
165
+ // notes, not manually seeded DB events in tests.
100
166
  const rec = { recordRetrievals: process.env.GATEWAY_RECORD_RETRIEVALS === '1' };
101
167
  let results;
102
168
  if (corpus === 'memory') {
103
169
  results = searchArchive(personalDb, query, maxResults, rec).map((h) => ({ ...h, corpus: 'memory' }));
104
170
  } else if (corpus === 'shared') {
105
171
  if (!sharedDb) return this.json({ results: [], unavailable: true, warning: 'shared KB is not enabled.' });
106
- results = searchArchive(sharedDb, query, maxResults).map((h) => ({ ...h, corpus: 'shared' }));
172
+ results = searchArchive(sharedDb, query, maxResults, rec).map((h) => ({ ...h, corpus: 'shared' }));
107
173
  } else {
108
174
  // "all": merge personal + shared, keep the best by bm25.
109
175
  type Tagged = ReturnType<typeof searchArchive>[number] & { corpus: string };
110
176
  const mine: Tagged[] = searchArchive(personalDb, query, maxResults, rec).map((h) => ({ ...h, corpus: 'memory' }));
111
177
  const shared: Tagged[] = sharedDb
112
- ? searchArchive(sharedDb, query, maxResults).map((h) => ({ ...h, corpus: 'shared' }))
178
+ ? searchArchive(sharedDb, query, maxResults, rec).map((h) => ({ ...h, corpus: 'shared' }))
113
179
  : [];
114
180
  results = mergeHits(mine, shared, maxResults);
115
181
  }
@@ -134,6 +200,126 @@ export class MemoryModule implements ToolModule {
134
200
  return this.json(excerpt);
135
201
  }
136
202
 
203
+ case 'memory_shared_create': {
204
+ // Same gate memory_search uses for corpus:"shared" — fail closed with a
205
+ // clear error rather than silently falling back to some default vault.
206
+ const vaultDir = process.env.GATEWAY_SHARED_KB_DIR;
207
+ if (!vaultDir || !vaultDir.trim()) {
208
+ return this.err('memory_shared_create unavailable: shared KB is not enabled.');
209
+ }
210
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
211
+ if (!name) return this.err('memory_shared_create requires non-empty "name".');
212
+ let content = typeof args.content === 'string' ? args.content : '';
213
+ if (!content.trim()) return this.err('memory_shared_create requires non-empty "content".');
214
+ if (content.length > MAX_SHARED_NOTE_SIZE) {
215
+ return this.err(`memory_shared_create: content exceeds ${MAX_SHARED_NOTE_SIZE / 1024}KB limit (${(content.length / 1024).toFixed(1)}KB).`);
216
+ }
217
+ if (sharedNoteExists(vaultDir, name)) {
218
+ return this.err(`memory_shared_create: a note named "${name}" already exists. Use memory_shared_update to modify it (read it first with memory_shared_get), or pick a different name.`);
219
+ }
220
+
221
+ const confirm = args.confirm === true;
222
+ const sharedDb = sharedDbPathFromEnv();
223
+ const similar = sharedDb ? findSimilarSharedNotes(sharedDb, `${name} ${content}`, 3) : [];
224
+ if (similar.length > 0) {
225
+ if (!confirm) {
226
+ return this.json({
227
+ created: false,
228
+ needsConfirmation: true,
229
+ reason: 'similar-notes-found',
230
+ similar: similar.map((h) => ({ path: h.path, snippet: h.snippet })),
231
+ message: `Found ${similar.length} existing note(s) with similar content — consider memory_shared_update on one of these instead of creating a new one. Pass confirm:true to create "${name}" anyway.`,
232
+ });
233
+ }
234
+ // Confirmed anyway: link to the related notes instead of leaving a
235
+ // disconnected duplicate (#386) — wiki.ts already resolves
236
+ // [[wikilinks]] into real /knowledge/graph edges, no parser change
237
+ // needed, this is the only writer that needs to start emitting them.
238
+ const links = similar
239
+ .map((h) => h.path.replace(/\.md$/i, ''))
240
+ .filter((n) => n !== name)
241
+ .map((n) => `[[${n}]]`);
242
+ if (links.length > 0) {
243
+ content = `${content.trim()}\n\nRelated: ${links.join(' ')}`;
244
+ if (content.length > MAX_SHARED_NOTE_SIZE) {
245
+ return this.err(`memory_shared_create: content plus related-note links exceeds ${MAX_SHARED_NOTE_SIZE / 1024}KB limit.`);
246
+ }
247
+ }
248
+ }
249
+
250
+ const notePath = writeSharedNoteAtomic(vaultDir, name, content);
251
+ triggerSharedReindex(vaultDir);
252
+ return this.json({ created: true, path: notePath });
253
+ }
254
+
255
+ case 'memory_shared_get': {
256
+ const vaultDir = process.env.GATEWAY_SHARED_KB_DIR;
257
+ if (!vaultDir || !vaultDir.trim()) {
258
+ return this.err('memory_shared_get unavailable: shared KB is not enabled.');
259
+ }
260
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
261
+ if (!name) return this.err('memory_shared_get requires non-empty "name".');
262
+ const content = readSharedNote(vaultDir, name);
263
+ if (content === null) {
264
+ return this.err(`memory_shared_get: no note named "${name}" found.`);
265
+ }
266
+ if (process.env.GATEWAY_RECORD_RETRIEVALS === '1' && sharedDb) {
267
+ // Shared notes are lifecycle-whole-file entries, so record the source
268
+ // note's indexed entry hash after a successful exact read (#392).
269
+ recordSharedPathRetrieval(sharedDb, sharedNoteFilename(name));
270
+ }
271
+ return this.json({ name, content });
272
+ }
273
+
274
+ case 'memory_shared_update': {
275
+ const vaultDir = process.env.GATEWAY_SHARED_KB_DIR;
276
+ if (!vaultDir || !vaultDir.trim()) {
277
+ return this.err('memory_shared_update unavailable: shared KB is not enabled.');
278
+ }
279
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
280
+ if (!name) return this.err('memory_shared_update requires non-empty "name".');
281
+ const content = typeof args.content === 'string' ? args.content : '';
282
+ if (!content.trim()) return this.err('memory_shared_update requires non-empty "content".');
283
+ if (content.length > MAX_SHARED_NOTE_SIZE) {
284
+ return this.err(`memory_shared_update: content exceeds ${MAX_SHARED_NOTE_SIZE / 1024}KB limit (${(content.length / 1024).toFixed(1)}KB).`);
285
+ }
286
+ const existing = readSharedNote(vaultDir, name);
287
+ if (existing === null) {
288
+ return this.err(`memory_shared_update: no note named "${name}" found. Use memory_shared_create instead.`);
289
+ }
290
+
291
+ const lossPercent = contentLossPercent(existing, content);
292
+ const confirm = args.confirm === true;
293
+ if (lossPercent >= UPDATE_LOSS_CONFIRM_THRESHOLD && !confirm) {
294
+ return this.json({
295
+ updated: false,
296
+ needsConfirmation: true,
297
+ reason: 'large-content-loss',
298
+ lossPercent,
299
+ message: `This update would remove ~${lossPercent}% of the existing note's lines. Pass confirm:true if that's intentional.`,
300
+ });
301
+ }
302
+
303
+ const notePath = writeSharedNoteAtomic(vaultDir, name, content);
304
+ triggerSharedReindex(vaultDir);
305
+ return this.json({ updated: true, path: notePath, lossPercent });
306
+ }
307
+
308
+ case 'memory_shared_delete': {
309
+ const vaultDir = process.env.GATEWAY_SHARED_KB_DIR;
310
+ if (!vaultDir || !vaultDir.trim()) {
311
+ return this.err('memory_shared_delete unavailable: shared KB is not enabled.');
312
+ }
313
+ const name = typeof args.name === 'string' ? args.name.trim() : '';
314
+ if (!name) return this.err('memory_shared_delete requires non-empty "name".');
315
+ const deleted = deleteSharedNote(vaultDir, name);
316
+ if (!deleted) {
317
+ return this.err(`memory_shared_delete: no note named "${name}" found.`);
318
+ }
319
+ triggerSharedReindex(vaultDir);
320
+ return this.json({ deleted: true, name });
321
+ }
322
+
137
323
  default:
138
324
  return this.err(`Unknown memory tool: ${name}`);
139
325
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared limits for the channel reply tools (Discord / Telegram / Slack).
3
+ *
4
+ * Self-contained on purpose, like share-client.ts: mcp/** ships as source
5
+ * without src/**, so this module must not import from src/ (see
6
+ * tests/unit/mcp-no-src-imports.test.ts).
7
+ */
8
+
9
+ /**
10
+ * Cap on a single outbound attachment. 50 MB is the smallest of the three
11
+ * platforms' own upload ceilings, so one value keeps the tools' behaviour
12
+ * identical instead of each re-declaring the same literal.
13
+ */
14
+ export const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;