@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.
- package/README.md +11 -3
- package/dist/agent/dreaming/accept.d.ts.map +1 -1
- package/dist/agent/dreaming/accept.js +4 -1
- package/dist/agent/dreaming/accept.js.map +1 -1
- package/dist/agent/dreaming/config.d.ts.map +1 -1
- package/dist/agent/dreaming/config.js +2 -0
- package/dist/agent/dreaming/config.js.map +1 -1
- package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
- package/dist/agent/dreaming/reviewer.js +9 -2
- package/dist/agent/dreaming/reviewer.js.map +1 -1
- package/dist/agent/dreaming/staleness.d.ts +7 -1
- package/dist/agent/dreaming/staleness.d.ts.map +1 -1
- package/dist/agent/dreaming/staleness.js +17 -2
- package/dist/agent/dreaming/staleness.js.map +1 -1
- package/dist/agent/dreaming/types.d.ts +17 -1
- package/dist/agent/dreaming/types.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.d.ts +43 -0
- package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
- package/dist/agent/knowledge/archive-db.js +130 -0
- package/dist/agent/knowledge/archive-db.js.map +1 -1
- package/dist/agent/knowledge/config.d.ts +3 -1
- package/dist/agent/knowledge/config.d.ts.map +1 -1
- package/dist/agent/knowledge/config.js +30 -1
- package/dist/agent/knowledge/config.js.map +1 -1
- package/dist/agent/knowledge/index.d.ts +11 -4
- package/dist/agent/knowledge/index.d.ts.map +1 -1
- package/dist/agent/knowledge/index.js +22 -1
- package/dist/agent/knowledge/index.js.map +1 -1
- package/dist/agent/knowledge/indexer.d.ts +0 -7
- package/dist/agent/knowledge/indexer.d.ts.map +1 -1
- package/dist/agent/knowledge/indexer.js +34 -7
- package/dist/agent/knowledge/indexer.js.map +1 -1
- package/dist/agent/knowledge/lifecycle.d.ts +20 -4
- package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
- package/dist/agent/knowledge/lifecycle.js +33 -4
- package/dist/agent/knowledge/lifecycle.js.map +1 -1
- package/dist/agent/knowledge/reflection.d.ts +114 -0
- package/dist/agent/knowledge/reflection.d.ts.map +1 -0
- package/dist/agent/knowledge/reflection.js +447 -0
- package/dist/agent/knowledge/reflection.js.map +1 -0
- package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
- package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-dedup.js +187 -0
- package/dist/agent/knowledge/shared-dedup.js.map +1 -0
- package/dist/agent/knowledge/shared-promote.d.ts +82 -7
- package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-promote.js +244 -11
- package/dist/agent/knowledge/shared-promote.js.map +1 -1
- package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
- package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
- package/dist/agent/knowledge/shared-staleness.js +149 -0
- package/dist/agent/knowledge/shared-staleness.js.map +1 -0
- package/dist/agent/knowledge/shared-writer.d.ts +19 -0
- package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
- package/dist/agent/knowledge/shared-writer.js +42 -0
- package/dist/agent/knowledge/shared-writer.js.map +1 -1
- package/dist/agent/knowledge/types.d.ts +24 -0
- package/dist/agent/knowledge/types.d.ts.map +1 -1
- package/dist/agent/runner.d.ts +7 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +106 -16
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/workspace-loader.d.ts +17 -2
- package/dist/agent/workspace-loader.d.ts.map +1 -1
- package/dist/agent/workspace-loader.js +17 -1
- package/dist/agent/workspace-loader.js.map +1 -1
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +2 -1
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/line-webhook-router.d.ts.map +1 -1
- package/dist/api/line-webhook-router.js +7 -15
- package/dist/api/line-webhook-router.js.map +1 -1
- package/dist/api/slack-client.d.ts +25 -2
- package/dist/api/slack-client.d.ts.map +1 -1
- package/dist/api/slack-client.js +102 -0
- package/dist/api/slack-client.js.map +1 -1
- package/dist/api/slack-webhook-router.d.ts +15 -1
- package/dist/api/slack-webhook-router.d.ts.map +1 -1
- package/dist/api/slack-webhook-router.js +128 -1
- package/dist/api/slack-webhook-router.js.map +1 -1
- package/dist/apps/compose-generator.js +7 -4
- package/dist/apps/compose-generator.js.map +1 -1
- package/dist/apps/installer.d.ts +86 -0
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +440 -52
- package/dist/apps/installer.js.map +1 -1
- package/dist/index.js +37 -1
- package/dist/index.js.map +1 -1
- package/dist/session/compactor.d.ts +32 -2
- package/dist/session/compactor.d.ts.map +1 -1
- package/dist/session/compactor.js +134 -19
- package/dist/session/compactor.js.map +1 -1
- package/dist/session/process.d.ts +20 -3
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +120 -5
- package/dist/session/process.js.map +1 -1
- package/dist/shared/image-sniff.d.ts +15 -0
- package/dist/shared/image-sniff.d.ts.map +1 -0
- package/dist/shared/image-sniff.js +29 -0
- package/dist/shared/image-sniff.js.map +1 -0
- package/dist/shell/claude-pty-shell.js +146 -27
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/draft-phantom.d.ts +49 -0
- package/dist/shell/draft-phantom.d.ts.map +1 -0
- package/dist/shell/draft-phantom.js +59 -0
- package/dist/shell/draft-phantom.js.map +1 -0
- package/dist/shell/submit-diag.d.ts +67 -0
- package/dist/shell/submit-diag.d.ts.map +1 -0
- package/dist/shell/submit-diag.js +69 -0
- package/dist/shell/submit-diag.js.map +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/mcp/tools/discord/module.ts +24 -17
- package/mcp/tools/memory/archive-reader.test.ts +25 -1
- package/mcp/tools/memory/archive-reader.ts +70 -12
- package/mcp/tools/memory/archive-writer.test.ts +168 -0
- package/mcp/tools/memory/archive-writer.ts +137 -0
- package/mcp/tools/memory/module.test.ts +187 -0
- package/mcp/tools/memory/module.ts +192 -6
- package/mcp/tools/shared/limits.ts +14 -0
- package/mcp/tools/slack/module.ts +66 -4
- package/mcp/tools/telegram/module.ts +11 -3
- package/mcp/tools/telegram/receiver-server.ts +10 -3
- package/mcp/tools/telegram/typing.ts +178 -34
- 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
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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;
|