@0xmaxma/claude-gateway 1.7.9 → 1.8.0
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 +178 -44
- package/dist/agent/builtin-commands.js +2 -2
- package/dist/agent/builtin-commands.js.map +1 -1
- 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 +32 -10
- 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/model-catalog.d.ts +62 -0
- package/dist/agent/model-catalog.d.ts.map +1 -0
- package/dist/agent/model-catalog.js +264 -0
- package/dist/agent/model-catalog.js.map +1 -0
- package/dist/agent/runner.d.ts +83 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +281 -51
- 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/cron-router.d.ts +12 -8
- package/dist/api/cron-router.d.ts.map +1 -1
- package/dist/api/cron-router.js +82 -16
- package/dist/api/cron-router.js.map +1 -1
- package/dist/api/gateway-router.d.ts +4 -0
- package/dist/api/gateway-router.d.ts.map +1 -1
- package/dist/api/gateway-router.js +16 -1
- package/dist/api/gateway-router.js.map +1 -1
- package/dist/api/manifest-sources.d.ts +15 -0
- package/dist/api/manifest-sources.d.ts.map +1 -0
- package/dist/api/manifest-sources.js +27 -0
- package/dist/api/manifest-sources.js.map +1 -0
- package/dist/api/meta-router.d.ts +13 -0
- package/dist/api/meta-router.d.ts.map +1 -0
- package/dist/api/meta-router.js +26 -0
- package/dist/api/meta-router.js.map +1 -0
- package/dist/api/packages.d.ts.map +1 -1
- package/dist/api/packages.js +18 -198
- package/dist/api/packages.js.map +1 -1
- package/dist/api/route-registry.d.ts +81 -0
- package/dist/api/route-registry.d.ts.map +1 -0
- package/dist/api/route-registry.js +44 -0
- package/dist/api/route-registry.js.map +1 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +21 -3
- package/dist/api/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 +154 -0
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +657 -54
- package/dist/apps/installer.js.map +1 -1
- package/dist/cli/args.d.ts +25 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +55 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/colors.d.ts +37 -0
- package/dist/cli/colors.d.ts.map +1 -0
- package/dist/cli/colors.js +63 -0
- package/dist/cli/colors.js.map +1 -0
- package/dist/cli/command-names.d.ts +98 -0
- package/dist/cli/command-names.d.ts.map +1 -0
- package/dist/cli/command-names.js +109 -0
- package/dist/cli/command-names.js.map +1 -0
- package/dist/cli/commands/agents.d.ts +28 -0
- package/dist/cli/commands/agents.d.ts.map +1 -0
- package/dist/cli/commands/agents.js +321 -0
- package/dist/cli/commands/agents.js.map +1 -0
- package/dist/cli/commands/channels.d.ts +17 -0
- package/dist/cli/commands/channels.d.ts.map +1 -0
- package/dist/cli/commands/channels.js +101 -0
- package/dist/cli/commands/channels.js.map +1 -0
- package/dist/cli/commands/debug-bundle.d.ts +6 -0
- package/dist/cli/commands/debug-bundle.d.ts.map +1 -0
- package/dist/cli/commands/debug-bundle.js +220 -0
- package/dist/cli/commands/debug-bundle.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +104 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/gateway.d.ts +12 -0
- package/dist/cli/commands/gateway.d.ts.map +1 -0
- package/dist/cli/commands/gateway.js +145 -0
- package/dist/cli/commands/gateway.js.map +1 -0
- package/dist/cli/commands/service.d.ts +33 -0
- package/dist/cli/commands/service.d.ts.map +1 -0
- package/dist/cli/commands/service.js +513 -0
- package/dist/cli/commands/service.js.map +1 -0
- package/dist/cli/commands/update.d.ts +16 -0
- package/dist/cli/commands/update.d.ts.map +1 -0
- package/dist/cli/commands/update.js +149 -0
- package/dist/cli/commands/update.js.map +1 -0
- package/dist/cli/commands.generated.d.ts +4 -0
- package/dist/cli/commands.generated.d.ts.map +1 -0
- package/dist/cli/commands.generated.js +220 -0
- package/dist/cli/commands.generated.js.map +1 -0
- package/dist/cli/health.d.ts +31 -0
- package/dist/cli/health.d.ts.map +1 -0
- package/dist/cli/health.js +49 -0
- package/dist/cli/health.js.map +1 -0
- package/dist/cli/http-client.d.ts +140 -0
- package/dist/cli/http-client.d.ts.map +1 -0
- package/dist/cli/http-client.js +332 -0
- package/dist/cli/http-client.js.map +1 -0
- package/dist/cli/index.d.ts +27 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +372 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/manager.d.ts +64 -0
- package/dist/cli/manager.d.ts.map +1 -0
- package/dist/cli/manager.js +181 -0
- package/dist/cli/manager.js.map +1 -0
- package/dist/cli/output.d.ts +49 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +101 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/prompt.d.ts +29 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/prompt.js +154 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli/redact.d.ts +3 -0
- package/dist/cli/redact.d.ts.map +1 -0
- package/dist/cli/redact.js +33 -0
- package/dist/cli/redact.js.map +1 -0
- package/dist/cli/types.d.ts +22 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +4 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/config/bootstrap.d.ts +33 -0
- package/dist/config/bootstrap.d.ts.map +1 -0
- package/dist/config/bootstrap.js +117 -0
- package/dist/config/bootstrap.js.map +1 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +5 -1
- package/dist/config/loader.js.map +1 -1
- package/dist/config/watcher.d.ts.map +1 -1
- package/dist/config/watcher.js +2 -35
- package/dist/config/watcher.js.map +1 -1
- package/dist/discord/receiver.d.ts +6 -1
- package/dist/discord/receiver.d.ts.map +1 -1
- package/dist/discord/receiver.js +9 -1
- package/dist/discord/receiver.js.map +1 -1
- package/dist/entry.d.ts +3 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +83 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.js +197 -47
- package/dist/index.js.map +1 -1
- package/dist/load-dotenv.d.ts +2 -0
- package/dist/load-dotenv.d.ts.map +1 -0
- package/dist/load-dotenv.js +68 -0
- package/dist/load-dotenv.js.map +1 -0
- package/dist/packages/registry.d.ts +50 -0
- package/dist/packages/registry.d.ts.map +1 -0
- package/dist/packages/registry.js +209 -0
- package/dist/packages/registry.js.map +1 -0
- 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/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/shutdown-signals.d.ts +47 -0
- package/dist/shutdown-signals.d.ts.map +1 -0
- package/dist/shutdown-signals.js +62 -0
- package/dist/shutdown-signals.js.map +1 -0
- package/dist/telegram/receiver.d.ts +6 -1
- package/dist/telegram/receiver.d.ts.map +1 -1
- package/dist/telegram/receiver.js +9 -1
- package/dist/telegram/receiver.js.map +1 -1
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/orphan-receivers.d.ts +90 -0
- package/dist/utils/orphan-receivers.d.ts.map +1 -0
- package/dist/utils/orphan-receivers.js +218 -0
- package/dist/utils/orphan-receivers.js.map +1 -0
- package/dist/utils/paths.d.ts +16 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +60 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/stop-child.d.ts +21 -0
- package/dist/utils/stop-child.d.ts.map +1 -0
- package/dist/utils/stop-child.js +64 -0
- package/dist/utils/stop-child.js.map +1 -0
- package/lib/pairing.ts +1 -1
- package/mcp/tools/agent/handlers.ts +1 -1
- package/mcp/tools/discord/module.ts +23 -15
- package/mcp/tools/discord/receiver-server.ts +4 -0
- 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/telegram/module.ts +10 -2
- package/mcp/tools/telegram/receiver-server.ts +15 -6
- package/mcp/tools/telegram/typing.ts +178 -34
- package/package.json +6 -4
|
@@ -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
|
}
|
|
@@ -363,10 +363,18 @@ export class TelegramModule implements ChannelModule {
|
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
// Write .replied marker
|
|
366
|
+
// Write .replied marker, tagged with the live typing-signal timestamp as
|
|
367
|
+
// this turn's id — the receiver (typing.ts) only treats a queued
|
|
368
|
+
// auto-forward as "already replied" when its own turnId matches this one,
|
|
369
|
+
// so a marker left over from an earlier turn can never suppress a later
|
|
370
|
+
// turn's forward (see typing.ts's isEntryAlreadyReplied()).
|
|
367
371
|
try {
|
|
368
372
|
fs.mkdirSync(this.typingDir, { recursive: true });
|
|
369
|
-
|
|
373
|
+
let turnId: string | null = null;
|
|
374
|
+
try {
|
|
375
|
+
turnId = fs.readFileSync(path.join(this.typingDir, chat_id), 'utf8').trim() || null;
|
|
376
|
+
} catch {}
|
|
377
|
+
fs.writeFileSync(path.join(this.typingDir, `${chat_id}.replied`), JSON.stringify({ text: sendText, turnId }));
|
|
370
378
|
} catch {}
|
|
371
379
|
|
|
372
380
|
const result = sentIds.length === 1
|
|
@@ -783,10 +783,17 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
|
783
783
|
|
|
784
784
|
// Typing persists until agent-runner sees result event + delay.
|
|
785
785
|
// Removed signalReplyDone() — reply does not mean done, agent may continue working.
|
|
786
|
-
// Write .replied marker so auto-forward in typing.ts skips duplicate send
|
|
786
|
+
// Write .replied marker so auto-forward in typing.ts skips duplicate send —
|
|
787
|
+
// tagged with the live typing-signal timestamp as this turn's id, so a
|
|
788
|
+
// marker left over from an earlier turn can never suppress a later
|
|
789
|
+
// turn's forward (see typing.ts's isEntryAlreadyReplied()).
|
|
787
790
|
try {
|
|
788
791
|
mkdirSync(TYPING_DIR, { recursive: true })
|
|
789
|
-
|
|
792
|
+
let turnId: string | null = null
|
|
793
|
+
try {
|
|
794
|
+
turnId = readFileSync(join(TYPING_DIR, chat_id), 'utf8').trim() || null
|
|
795
|
+
} catch {}
|
|
796
|
+
writeFileSync(join(TYPING_DIR, `${chat_id}.replied`), JSON.stringify({ text: sendText, turnId }))
|
|
790
797
|
} catch { /* non-fatal */ }
|
|
791
798
|
|
|
792
799
|
const result =
|
|
@@ -871,10 +878,12 @@ const BOT_COMMANDS = [
|
|
|
871
878
|
{ command: 'help', description: 'What this bot can do' },
|
|
872
879
|
]
|
|
873
880
|
|
|
874
|
-
// Available AI models for /models command.
|
|
875
|
-
// uses
|
|
876
|
-
//
|
|
877
|
-
//
|
|
881
|
+
// Available AI models for /models command. Last-resort fallback: the picker
|
|
882
|
+
// normally uses get_models, which itself resolves the live catalog and only
|
|
883
|
+
// then falls back to config.json's gateway.models (issue #409). This copy is
|
|
884
|
+
// reached only when the gateway call itself fails, so it is two fallbacks deep
|
|
885
|
+
// and will drift — keep it roughly in step with DEFAULT_MODELS
|
|
886
|
+
// (src/agent/runner.ts) and config.template.json gateway.models.
|
|
878
887
|
const AVAILABLE_MODELS = [
|
|
879
888
|
{ id: 'claude-fable-5[1m]', label: 'Fable 5 (1M)', alias: 'fable[1m]' },
|
|
880
889
|
{ id: 'claude-opus-5[1m]', label: 'Opus 5 (1M)', alias: 'opus[1m]' },
|
|
@@ -173,6 +173,47 @@ export const STATUS_EMOJI: Record<string, string> = {
|
|
|
173
173
|
error: '😱',
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Telegram reports failures as `description` on a GrammyError; anything else
|
|
178
|
+
* thrown here (a network error, a plain Error in tests) carries `message`.
|
|
179
|
+
*
|
|
180
|
+
* Read structurally rather than with `err instanceof GrammyError &&
|
|
181
|
+
* err.error_code`, the convention in receiver-server.ts, for two reasons: this
|
|
182
|
+
* module deliberately has no grammy import — it takes an injected BotApi so
|
|
183
|
+
* tests can drive it — and the two cases below are both HTTP 400, so the code
|
|
184
|
+
* alone cannot tell them apart. The description is the only thing that can.
|
|
185
|
+
*/
|
|
186
|
+
function errorText(err: unknown): string {
|
|
187
|
+
if (typeof err === 'object' && err !== null) {
|
|
188
|
+
const e = err as { description?: unknown; message?: unknown }
|
|
189
|
+
if (typeof e.description === 'string') return e.description
|
|
190
|
+
if (typeof e.message === 'string') return e.message
|
|
191
|
+
}
|
|
192
|
+
return String(err)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Telegram rejects an edit whose new text is byte-identical to what the
|
|
197
|
+
* message already shows. That is not a failure to recover from — the message
|
|
198
|
+
* is intact and already correct — so the status loop must keep editing it
|
|
199
|
+
* rather than concluding it is gone and posting a replacement.
|
|
200
|
+
*/
|
|
201
|
+
export function isNotModifiedError(err: unknown): boolean {
|
|
202
|
+
return /message is not modified/i.test(errorText(err))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The message can never be edited again: deleted, or past Telegram's edit
|
|
207
|
+
* window. Only these justify dropping the id and sending a fresh message.
|
|
208
|
+
* A transient failure (network, 429) deliberately does NOT — the message is
|
|
209
|
+
* still there, and re-sending would leave a duplicate behind.
|
|
210
|
+
*/
|
|
211
|
+
export function isMessageGoneError(err: unknown): boolean {
|
|
212
|
+
return /message to (?:edit|be edited) not found|message can'?t be edited|message not found/i.test(
|
|
213
|
+
errorText(err),
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
176
217
|
export function parseStatusFile(content: string): { status: string; detail?: string } {
|
|
177
218
|
try {
|
|
178
219
|
const parsed = JSON.parse(content);
|
|
@@ -193,6 +234,10 @@ export interface WorkingState {
|
|
|
193
234
|
currentReaction: string | null
|
|
194
235
|
lastDetail: string | null
|
|
195
236
|
recentDetails: string[]
|
|
237
|
+
/** Text the status message currently displays. An edit that would rewrite a
|
|
238
|
+
* message with the exact text it already has is rejected by Telegram, so it
|
|
239
|
+
* is never issued. */
|
|
240
|
+
lastStatusText: string | null
|
|
196
241
|
/** Last stage an incident was raised for — dedupes the turn-trace watchdog
|
|
197
242
|
* so one contiguous stalled episode emits a single incident, not one per
|
|
198
243
|
* 15s tick. Reset to null once the turn is no longer stalled. */
|
|
@@ -216,6 +261,78 @@ export interface FsApi {
|
|
|
216
261
|
statSync(path: string): { mtimeMs: number }
|
|
217
262
|
}
|
|
218
263
|
|
|
264
|
+
/**
|
|
265
|
+
* A single queued auto-forward. `turnId` identifies the turn that produced it
|
|
266
|
+
* (the live typing-signal timestamp read at write time) — `null` when no
|
|
267
|
+
* signal file existed yet (e.g. an orphan forward from an autonomous wake
|
|
268
|
+
* with no inbound message). Writers: `AgentRunner.writeAutoForward()`.
|
|
269
|
+
*/
|
|
270
|
+
export interface ForwardEntry {
|
|
271
|
+
text: string
|
|
272
|
+
format: 'text' | 'html'
|
|
273
|
+
turnId: string | null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function normalizeForwardEntry(raw: unknown): ForwardEntry | null {
|
|
277
|
+
if (raw && typeof raw === 'object' && typeof (raw as { text?: unknown }).text === 'string') {
|
|
278
|
+
const o = raw as { text: string; format?: unknown; turnId?: unknown }
|
|
279
|
+
return {
|
|
280
|
+
text: o.text,
|
|
281
|
+
format: o.format === 'html' ? 'html' : 'text',
|
|
282
|
+
turnId: typeof o.turnId === 'string' ? o.turnId : null,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Parse `.forward` file content into an ordered queue of entries. Accepts the
|
|
290
|
+
* current queue format (a JSON array), the pre-queue single-object format,
|
|
291
|
+
* and the original plain-text format — so an in-flight file survives a
|
|
292
|
+
* rolling deploy in either direction instead of losing its content.
|
|
293
|
+
*/
|
|
294
|
+
export function parseForwardQueue(raw: string): ForwardEntry[] {
|
|
295
|
+
const trimmed = raw.trim()
|
|
296
|
+
if (!trimmed) return []
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(trimmed)
|
|
299
|
+
if (Array.isArray(parsed)) {
|
|
300
|
+
return parsed.map(normalizeForwardEntry).filter((e): e is ForwardEntry => e !== null)
|
|
301
|
+
}
|
|
302
|
+
const single = normalizeForwardEntry(parsed)
|
|
303
|
+
return single ? [single] : []
|
|
304
|
+
} catch {
|
|
305
|
+
return [{ text: trimmed, format: 'text', turnId: null }]
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Parse `.replied` marker content. Current format is `{ text, turnId }`; a
|
|
311
|
+
* legacy marker (plain text, or JSON without a `turnId`) parses to
|
|
312
|
+
* `turnId: null`, which — per `resolveForwardDelivery` below — never
|
|
313
|
+
* satisfies dedup, so a marker from a version predating this format degrades
|
|
314
|
+
* to "deliver" rather than risking a silent drop.
|
|
315
|
+
*/
|
|
316
|
+
function parseRepliedTurnId(raw: string): string | null {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(raw.trim()) as { turnId?: unknown }
|
|
319
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.turnId === 'string') {
|
|
320
|
+
return parsed.turnId
|
|
321
|
+
}
|
|
322
|
+
} catch {}
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Turn-scoped dedup: an entry is already covered by the reply tool only when
|
|
328
|
+
* both sides captured the SAME live turn id at write time. A `null` turnId on
|
|
329
|
+
* either side never matches — favors an extra/duplicate delivery over a
|
|
330
|
+
* silently dropped one.
|
|
331
|
+
*/
|
|
332
|
+
function isEntryAlreadyReplied(entry: ForwardEntry, repliedTurnId: string | null): boolean {
|
|
333
|
+
return repliedTurnId !== null && entry.turnId !== null && entry.turnId === repliedTurnId
|
|
334
|
+
}
|
|
335
|
+
|
|
219
336
|
/**
|
|
220
337
|
* Deliver auto-forwarded turn text to a chat, never silently dropping content.
|
|
221
338
|
* Each chunk that fails as HTML (e.g. Telegram rejects an entity the balancer
|
|
@@ -292,22 +409,18 @@ export function drainOrphanForwards(
|
|
|
292
409
|
// Remove BEFORE sending so a slow/failing send can't double-deliver.
|
|
293
410
|
fsApi.rmSync(forwardPath, { force: true })
|
|
294
411
|
// Mirror stop()'s dedup: a lingering .replied with no typing state means
|
|
295
|
-
// the agent already sent
|
|
412
|
+
// the agent already sent SOME turn's text via the reply tool — but only
|
|
413
|
+
// entries whose turnId matches that marker's turn are actually covered.
|
|
296
414
|
const repliedPath = `${typingDir}/${chatId}.replied`
|
|
415
|
+
let repliedTurnId: string | null = null
|
|
297
416
|
if (fsApi.existsSync(repliedPath)) {
|
|
417
|
+
try { repliedTurnId = parseRepliedTurnId(fsApi.readFileSync(repliedPath, 'utf8')) } catch {}
|
|
298
418
|
fsApi.rmSync(repliedPath, { force: true })
|
|
299
|
-
continue
|
|
300
419
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const parsed = JSON.parse(raw) as { text: string; format: string }
|
|
305
|
-
text = parsed.text
|
|
306
|
-
parseMode = parsed.format === 'html' ? 'HTML' : undefined
|
|
307
|
-
} catch {
|
|
308
|
-
// Old format: plain text
|
|
420
|
+
for (const entry of parseForwardQueue(raw)) {
|
|
421
|
+
if (!entry.text || isEntryAlreadyReplied(entry, repliedTurnId)) continue
|
|
422
|
+
void deliverForwardText(botApi, chatId, entry.text, entry.format === 'html' ? 'HTML' : undefined)
|
|
309
423
|
}
|
|
310
|
-
if (text) void deliverForwardText(botApi, chatId, text, parseMode)
|
|
311
424
|
}
|
|
312
425
|
}
|
|
313
426
|
|
|
@@ -472,30 +585,29 @@ export function createWorkingStateManager(
|
|
|
472
585
|
}
|
|
473
586
|
} catch {}
|
|
474
587
|
}
|
|
475
|
-
// Auto-forward
|
|
588
|
+
// Auto-forward queued turn text to Telegram, skipping only the entries the
|
|
589
|
+
// reply tool already sent (same turnId as the .replied marker) — turn A's
|
|
590
|
+
// leftover marker must never suppress turn B's forward. See
|
|
591
|
+
// isEntryAlreadyReplied() / the module docstring for the race this guards.
|
|
476
592
|
const forwardPath = forwardFilePath(chatId)
|
|
477
593
|
const repliedPath = repliedFilePath(chatId)
|
|
478
594
|
if (fsApi.existsSync(forwardPath)) {
|
|
595
|
+
let entries: ForwardEntry[] = []
|
|
479
596
|
try {
|
|
480
|
-
|
|
481
|
-
let forwardText: string
|
|
482
|
-
let parseMode: 'HTML' | undefined
|
|
483
|
-
try {
|
|
484
|
-
const parsed = JSON.parse(raw) as { text: string; format: string }
|
|
485
|
-
forwardText = parsed.text
|
|
486
|
-
parseMode = parsed.format === 'html' ? 'HTML' : undefined
|
|
487
|
-
} catch {
|
|
488
|
-
// Fallback: treat as plain text (old format compatibility)
|
|
489
|
-
forwardText = raw
|
|
490
|
-
parseMode = undefined
|
|
491
|
-
}
|
|
492
|
-
// Skip if the reply tool already sent a message (agent already replied)
|
|
493
|
-
const alreadyReplied = fsApi.existsSync(repliedPath)
|
|
494
|
-
if (!alreadyReplied && forwardText) {
|
|
495
|
-
await deliverForwardText(botApi, chatId, forwardText, parseMode)
|
|
496
|
-
}
|
|
597
|
+
entries = parseForwardQueue(fsApi.readFileSync(forwardPath, 'utf8'))
|
|
497
598
|
} catch {}
|
|
599
|
+
// Remove BEFORE delivering (mirrors drainOrphanForwards' same comment):
|
|
600
|
+
// delivery can take a while (multiple chunks, retries), and a
|
|
601
|
+
// writeAutoForward() append landing mid-delivery must start a fresh
|
|
602
|
+
// file rather than have this call's cleanup silently discard it.
|
|
498
603
|
fsApi.rmSync(forwardPath, { force: true })
|
|
604
|
+
const repliedTurnId = fsApi.existsSync(repliedPath)
|
|
605
|
+
? parseRepliedTurnId(fsApi.readFileSync(repliedPath, 'utf8'))
|
|
606
|
+
: null
|
|
607
|
+
for (const entry of entries) {
|
|
608
|
+
if (!entry.text || isEntryAlreadyReplied(entry, repliedTurnId)) continue
|
|
609
|
+
await deliverForwardText(botApi, chatId, entry.text, entry.format === 'html' ? 'HTML' : undefined)
|
|
610
|
+
}
|
|
499
611
|
}
|
|
500
612
|
// NOTE: interactive-menu (.menu) delivery is handled by an independent poller
|
|
501
613
|
// in receiver-server.ts (drainMenuFiles), NOT here. A menu can be emitted
|
|
@@ -551,6 +663,7 @@ export function createWorkingStateManager(
|
|
|
551
663
|
currentReaction: null,
|
|
552
664
|
lastDetail: null,
|
|
553
665
|
recentDetails: [],
|
|
666
|
+
lastStatusText: null,
|
|
554
667
|
lastIncidentStage: null,
|
|
555
668
|
}
|
|
556
669
|
states.set(chatId, state)
|
|
@@ -583,13 +696,44 @@ export function createWorkingStateManager(
|
|
|
583
696
|
if (s.statusMessageId === null) {
|
|
584
697
|
try {
|
|
585
698
|
const sent = await botApi.sendMessage(chatId, text)
|
|
586
|
-
s
|
|
699
|
+
// Re-read rather than writing through the captured `s`: the turn
|
|
700
|
+
// can end while the send is in flight, and the state this records
|
|
701
|
+
// against must be the live one or none at all.
|
|
702
|
+
const current = states.get(chatId)
|
|
703
|
+
if (current) {
|
|
704
|
+
current.statusMessageId = sent.message_id
|
|
705
|
+
current.lastStatusText = text
|
|
706
|
+
}
|
|
587
707
|
} catch {}
|
|
588
|
-
} else {
|
|
589
|
-
|
|
708
|
+
} else if (text !== s.lastStatusText) {
|
|
709
|
+
// Nothing to say, so nothing is sent. The cost is that a status
|
|
710
|
+
// message deleted by hand is not noticed until the next edit — but
|
|
711
|
+
// the elapsed line advances every minute even at `Xh Ym`, so that
|
|
712
|
+
// wait is bounded, and it buys immunity from the duplicate loop.
|
|
713
|
+
try {
|
|
714
|
+
await botApi.editMessageText(chatId, s.statusMessageId, text)
|
|
715
|
+
s.lastStatusText = text
|
|
716
|
+
} catch (err) {
|
|
590
717
|
const current = states.get(chatId)
|
|
591
|
-
if (current)
|
|
592
|
-
|
|
718
|
+
if (!current) {
|
|
719
|
+
// The turn ended while the edit was in flight.
|
|
720
|
+
} else if (isNotModifiedError(err)) {
|
|
721
|
+
// The message already shows this text — nothing to recover from.
|
|
722
|
+
current.lastStatusText = text
|
|
723
|
+
} else if (isMessageGoneError(err)) {
|
|
724
|
+
current.statusMessageId = null
|
|
725
|
+
current.lastStatusText = null
|
|
726
|
+
} else {
|
|
727
|
+
// Keep the id — the message is still there, and the next tick
|
|
728
|
+
// edits it again; re-sending would leave a duplicate behind.
|
|
729
|
+
// Logged because a silently swallowed edit failure is exactly
|
|
730
|
+
// what let the duplicate loop run unnoticed: if Telegram ever
|
|
731
|
+
// returns something this does not classify, it should be
|
|
732
|
+
// visible in the receiver log rather than inferred from
|
|
733
|
+
// symptoms.
|
|
734
|
+
console.error(`[typing] status edit failed (retrying next tick): ${errorText(err)}`)
|
|
735
|
+
}
|
|
736
|
+
}
|
|
593
737
|
}
|
|
594
738
|
} finally {
|
|
595
739
|
statusUpdatePending = false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xmaxma/claude-gateway",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Multi-agent gateway for Claude",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"bin": {
|
|
11
|
-
"claude-gateway": "./dist/
|
|
11
|
+
"claude-gateway": "./dist/entry.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=22"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc",
|
|
31
|
-
"start": "node --no-warnings=ExperimentalWarning --env-file-if-exists=.env dist/
|
|
31
|
+
"start": "node --no-warnings=ExperimentalWarning --env-file-if-exists=.env dist/entry.js gateway start",
|
|
32
32
|
"postinstall": "if command -v bun >/dev/null 2>&1; then bun install --cwd mcp; else echo 'Warning: bun not found. Run `cd mcp && bun install` manually to enable MCP tools.'; fi",
|
|
33
33
|
"pretest": "npm run build",
|
|
34
34
|
"test": "NODE_OPTIONS=--max-old-space-size=2560 jest",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
"test:manual": "jest --testPathPattern=phase-manual --testTimeout=15000",
|
|
41
41
|
"integration:hb": "jest --testPathPattern=heartbeat-e2e --testTimeout=15000",
|
|
42
42
|
"integration:cs": "jest --testPathPattern=character-system --testTimeout=15000",
|
|
43
|
-
"typecheck": "tsc --noEmit"
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"gen-cli": "ts-node scripts/gen-cli.ts",
|
|
45
|
+
"gen-cli:check": "ts-node scripts/gen-cli.ts --check"
|
|
44
46
|
},
|
|
45
47
|
"dependencies": {
|
|
46
48
|
"@line/bot-sdk": "^11.0.2",
|