@elvisw/dsh-magic-context 0.0.0-bootstrap.0 → 0.0.0-stage
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 +2 -452
- package/package.json +4 -108
- package/LICENSE +0 -23
- package/NOTICE +0 -93
- package/README.zh-CN.md +0 -368
- package/cordis.patch.yml +0 -20
- package/lib/client.js +0 -2270
- package/lib/core/compartments.js +0 -424
- package/lib/core/corpus-store.js +0 -176
- package/lib/core/directive-detect.js +0 -147
- package/lib/core/domain-reconcile.js +0 -394
- package/lib/core/domains.js +0 -127
- package/lib/core/embedding-local.js +0 -247
- package/lib/core/global-autostage.js +0 -223
- package/lib/core/global-domains.js +0 -302
- package/lib/core/journal.js +0 -248
- package/lib/core/llm-deadline.js +0 -53
- package/lib/core/machine-domain.js +0 -106
- package/lib/core/maintenance.js +0 -380
- package/lib/core/memory-bridge.js +0 -818
- package/lib/core/memory-categories.js +0 -84
- package/lib/core/memory-scoring.js +0 -65
- package/lib/core/memory-search.js +0 -414
- package/lib/core/memory-write.js +0 -1176
- package/lib/core/policy.js +0 -111
- package/lib/core/profile-confirmation.js +0 -55
- package/lib/core/profile-review.js +0 -375
- package/lib/core/profile-write.js +0 -283
- package/lib/core/reference-seeds.js +0 -63
- package/lib/core/route-health.js +0 -106
- package/lib/core/stores.js +0 -1021
- package/lib/core/tagger.js +0 -7
- package/lib/core/types.js +0 -15
- package/lib/core/vector-backfill.js +0 -140
- package/lib/core/vector-lane.js +0 -184
- package/lib/dsh/command-surface.js +0 -376
- package/lib/dsh/compaction-engine.js +0 -231
- package/lib/dsh/config-switches.js +0 -56
- package/lib/dsh/config.js +0 -13
- package/lib/dsh/context-pressure.js +0 -36
- package/lib/dsh/drop-landing.js +0 -42
- package/lib/dsh/emergency.js +0 -89
- package/lib/dsh/flush-command.js +0 -148
- package/lib/dsh/global-review.js +0 -412
- package/lib/dsh/historian.js +0 -907
- package/lib/dsh/live-journal.js +0 -81
- package/lib/dsh/maintenance-wiring.js +0 -229
- package/lib/dsh/management-pass.js +0 -564
- package/lib/dsh/memory-lane.js +0 -535
- package/lib/dsh/pre-step.js +0 -675
- package/lib/dsh/project-drift.js +0 -116
- package/lib/dsh/recomp.js +0 -192
- package/lib/dsh/reduce-guidance.js +0 -188
- package/lib/dsh/seams.js +0 -588
- package/lib/dsh/search-hint.js +0 -186
- package/lib/dsh/session-notice.js +0 -100
- package/lib/dsh/status-command.js +0 -75
- package/lib/dsh/telemetry.js +0 -349
- package/lib/dsh/tool-bridge.js +0 -1009
- package/lib/dsh/volatile.js +0 -89
- package/lib/index.js +0 -931
- package/tools/migrate-profile-package-name.mjs +0 -317
- package/vendor/onnx/LICENSE +0 -27
- package/vendor/onnx/ort-wasm-simd-threaded.mjs +0 -59
- package/vendor/onnx/ort-wasm-simd-threaded.wasm +0 -0
- package/vendor/onnx/ort.node.min.mjs +0 -8
package/lib/dsh/tool-bridge.js
DELETED
|
@@ -1,1009 +0,0 @@
|
|
|
1
|
-
import { estimateTokens } from "../core/compartments.js";
|
|
2
|
-
import { parseTagRanges } from "../core/policy.js";
|
|
3
|
-
import { enumerateSurfaceToolResults, originalContentOfToolCall, toolResultAt, } from "./seams.js";
|
|
4
|
-
/** Probe for the loading-mode diagnostics: resolves the dynamic-import constructor. */
|
|
5
|
-
export function resolveDefineTool() {
|
|
6
|
-
// The host builder takes its own (wider) option type; the seam names the
|
|
7
|
-
// subset we author, so the call sites keep their parameter types.
|
|
8
|
-
return import('@deepseek-ai/dsh-tools').then(module => module.defineTool);
|
|
9
|
-
}
|
|
10
|
-
/**
|
|
11
|
-
* The session id, when the host supplied a run context. A tool must never
|
|
12
|
-
* throw because the context is missing (a host may call it bare): the memory
|
|
13
|
-
* tool then answers without a session and leaves the recall decision to the
|
|
14
|
-
* host's callback.
|
|
15
|
-
*/
|
|
16
|
-
function sessionIdOf(exec) {
|
|
17
|
-
return exec?.agent?.session.id;
|
|
18
|
-
}
|
|
19
|
-
function ackText(lines) {
|
|
20
|
-
return [{ type: 'text', text: lines.join('\n') }];
|
|
21
|
-
}
|
|
22
|
-
/** Decimal spelling shared by `message` and range bounds; no whitespace or sign. */
|
|
23
|
-
const DECIMAL_SEQ = /^(?:0|[1-9]\d*)$/;
|
|
24
|
-
/** Validate one range bound spelling without ever number-coercing it. */
|
|
25
|
-
function rangeBound(raw, label) {
|
|
26
|
-
if (typeof raw !== 'string' || !DECIMAL_SEQ.test(raw)) {
|
|
27
|
-
return { reason: `${label} must be a non-negative decimal integer string` };
|
|
28
|
-
}
|
|
29
|
-
const value = Number(raw);
|
|
30
|
-
if (!Number.isSafeInteger(value)) {
|
|
31
|
-
return { reason: `${label} must be a non-negative decimal integer string` };
|
|
32
|
-
}
|
|
33
|
-
return { value };
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* Resolve `tag` (canonical, single), `tags` (deprecated multi-tag alias),
|
|
37
|
-
* `message` (DSH log event seq; T3), or an inclusive `start`/`end` range
|
|
38
|
-
* (V1.1). Empty optional strings are absent except for a lone `message`, which
|
|
39
|
-
* is still an invalid value rather than a silent fall-through.
|
|
40
|
-
*
|
|
41
|
-
* Precedence is deliberately range-first: a real range plus another locator is
|
|
42
|
-
* a mixed-locator error even when the other locator pair is itself invalid;
|
|
43
|
-
* host filler `start="0",end="0"` is ignored when a tag/message is present.
|
|
44
|
-
*/
|
|
45
|
-
function expandRequest(args) {
|
|
46
|
-
const tag = typeof args.tag === 'string' ? args.tag.trim() : '';
|
|
47
|
-
const tags = typeof args.tags === 'string' ? args.tags.trim() : '';
|
|
48
|
-
const rangeIntent = (args.start !== undefined && args.start !== '')
|
|
49
|
-
|| (args.end !== undefined && args.end !== '');
|
|
50
|
-
if (args.verbose !== undefined && typeof args.verbose !== 'boolean') {
|
|
51
|
-
return rangeIntent ? { reason: 'verbose must be a boolean', locator: 'range' } : { reason: 'verbose must be a boolean' };
|
|
52
|
-
}
|
|
53
|
-
const verbose = args.verbose === true;
|
|
54
|
-
const startProvided = args.start !== undefined && args.start !== '';
|
|
55
|
-
const endProvided = args.end !== undefined && args.end !== '';
|
|
56
|
-
if (startProvided !== endProvided) {
|
|
57
|
-
return { reason: 'pass both start and end (DSH log event seq, inclusive)', locator: 'range' };
|
|
58
|
-
}
|
|
59
|
-
const hasTagOrMessage = tag !== '' || tags !== '' || (args.message !== undefined && args.message !== '');
|
|
60
|
-
if (startProvided && endProvided) {
|
|
61
|
-
// Upstream sends this filler when a range-capable host also fills the
|
|
62
|
-
// bounds; it means "no range" only when another locator is present.
|
|
63
|
-
const filler = args.start === '0' && args.end === '0';
|
|
64
|
-
if (!(filler && hasTagOrMessage)) {
|
|
65
|
-
if (hasTagOrMessage) {
|
|
66
|
-
return { reason: 'pass either tag, tags, message, or start/end, not more than one', locator: 'range' };
|
|
67
|
-
}
|
|
68
|
-
const start = rangeBound(args.start, 'start');
|
|
69
|
-
if ('reason' in start)
|
|
70
|
-
return { ...start, locator: 'range' };
|
|
71
|
-
const end = rangeBound(args.end, 'end');
|
|
72
|
-
if ('reason' in end)
|
|
73
|
-
return { ...end, locator: 'range' };
|
|
74
|
-
if (start.value > end.value)
|
|
75
|
-
return { reason: 'start must not be greater than end', locator: 'range' };
|
|
76
|
-
return { kind: 'range', start: start.value, end: end.value, verbose };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
if (tag !== '' && tags !== '')
|
|
80
|
-
return { reason: 'pass either tag or tags, not both' };
|
|
81
|
-
// An empty optional string is how a host often spells "unset" (tag/tags do
|
|
82
|
-
// the same above). Ignore it only when another locator is present; alone it
|
|
83
|
-
// is still an invalid `message`, not a silent fall-through to "no tag".
|
|
84
|
-
if (args.message !== undefined && args.message !== '') {
|
|
85
|
-
if (tag !== '' || tags !== '') {
|
|
86
|
-
return { reason: 'pass either tag, tags, or message, not more than one' };
|
|
87
|
-
}
|
|
88
|
-
if (typeof args.message !== 'string' || !DECIMAL_SEQ.test(args.message)) {
|
|
89
|
-
return { reason: 'message must be a non-negative decimal integer string' };
|
|
90
|
-
}
|
|
91
|
-
const seq = Number(args.message);
|
|
92
|
-
if (!Number.isSafeInteger(seq)) {
|
|
93
|
-
return { reason: `"${args.message}" is outside the safe integer range` };
|
|
94
|
-
}
|
|
95
|
-
return { kind: 'message', message: seq };
|
|
96
|
-
}
|
|
97
|
-
if (tag === '' && tags === '') {
|
|
98
|
-
if (args.message !== undefined) {
|
|
99
|
-
return { reason: 'message must be a non-negative decimal integer string' };
|
|
100
|
-
}
|
|
101
|
-
if (args.verbose !== undefined)
|
|
102
|
-
return { reason: 'verbose requires start and end', locator: 'range' };
|
|
103
|
-
if (args.start !== undefined || args.end !== undefined) {
|
|
104
|
-
return { reason: 'start and end must both be provided', locator: 'range' };
|
|
105
|
-
}
|
|
106
|
-
return { reason: 'no tag given (pass tag: "N")' };
|
|
107
|
-
}
|
|
108
|
-
const source = tag !== '' ? tag : tags;
|
|
109
|
-
// Request order is the payload order (contract D4/Q3): "9,2" restores 9
|
|
110
|
-
// first. Only this path keeps it; `ctx_reduce` stays ascending.
|
|
111
|
-
const parsed = parseTagRanges(source, { preserveOrder: true });
|
|
112
|
-
if (parsed.length === 0)
|
|
113
|
-
return { reason: `"${source}" names no tag number` };
|
|
114
|
-
if (tag !== '' && parsed.length !== 1) {
|
|
115
|
-
return { reason: `"${source}" is not a single tag number (use tags for several)` };
|
|
116
|
-
}
|
|
117
|
-
return { kind: 'tags', tags: parsed };
|
|
118
|
-
}
|
|
119
|
-
/** The post-`getTag` gate shared by the tag and message locators. */
|
|
120
|
-
function restoreResolvedTag(deps, sessionId, tag, original) {
|
|
121
|
-
const state = deps.stores.dropState(sessionId, tag);
|
|
122
|
-
if (state === 'pending')
|
|
123
|
-
return { reason: 'not dropped yet' };
|
|
124
|
-
if (original.kind === 'missing-event')
|
|
125
|
-
return { reason: 'tag no longer available' };
|
|
126
|
-
// A landed drop has a placeholder event in the log; an emergency drop
|
|
127
|
-
// bypassed the queue but left the same placeholder behind. The guard is
|
|
128
|
-
// sound because the post-execute hook prefixes EVERY tagged tool result, so
|
|
129
|
-
// an unprefixed, placeholder-shaped event can only be a drop.
|
|
130
|
-
if (state !== 'landed' && original.droppedTag === undefined) {
|
|
131
|
-
return { reason: 'not dropped' };
|
|
132
|
-
}
|
|
133
|
-
if (original.kind === 'missing-source')
|
|
134
|
-
return { reason: 'raw source unavailable' };
|
|
135
|
-
return { content: original.content };
|
|
136
|
-
}
|
|
137
|
-
/** One payload per restored tag, in request order (contract D4/Q3). */
|
|
138
|
-
function joinExpandPayloads(payloads) {
|
|
139
|
-
if (payloads.length === 0)
|
|
140
|
-
return undefined;
|
|
141
|
-
const blocks = [];
|
|
142
|
-
for (const [index, content] of payloads.entries()) {
|
|
143
|
-
// The separator is a block so non-text payloads are carried through
|
|
144
|
-
// unchanged; a text-only projection reads `p1\n\n---\n\np2`.
|
|
145
|
-
if (index > 0)
|
|
146
|
-
blocks.push({ type: 'text', text: '\n\n---\n\n' });
|
|
147
|
-
blocks.push(...content);
|
|
148
|
-
}
|
|
149
|
-
return blocks;
|
|
150
|
-
}
|
|
151
|
-
/** V1.1 range budget: chars/4, same estimate as the rest of the adapter. */
|
|
152
|
-
const CTX_EXPAND_RANGE_TOKEN_BUDGET = 15_000;
|
|
153
|
-
/** Verbose preview length per part (upstream-compatible). */
|
|
154
|
-
const CTX_EXPAND_PREVIEW_CHARS = 200;
|
|
155
|
-
/** Reserve for the header, continuation, and token-estimate rounding. */
|
|
156
|
-
const CTX_EXPAND_HEADER_RESERVE = 400;
|
|
157
|
-
const COMPACTED_RANGE_TEXT = /^\[compacted §C\d+§\]$/;
|
|
158
|
-
const EXCLUDED_RANGE_SOURCE_KINDS = new Set(['magic-context', 'compact-checkpoint']);
|
|
159
|
-
function isRangeReadableEvent(event) {
|
|
160
|
-
return event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'tool/result';
|
|
161
|
-
}
|
|
162
|
-
function rangeContent(event) {
|
|
163
|
-
return event.type === 'user/message' ? event.data.content : event.data.message.content;
|
|
164
|
-
}
|
|
165
|
-
function rangeSourceKind(event) {
|
|
166
|
-
const source = event.type === 'user/message' ? event.data.source : event.data.message.source;
|
|
167
|
-
return source?.kind;
|
|
168
|
-
}
|
|
169
|
-
/** Exclude adapter/host bookkeeping and compacted markers from transcript output. */
|
|
170
|
-
function isExcludedRangeEvent(event) {
|
|
171
|
-
const kind = rangeSourceKind(event);
|
|
172
|
-
// A user message belongs in the transcript only when it came from the user.
|
|
173
|
-
// Adapter notices, compaction checkpoints and other plugin-injected user
|
|
174
|
-
// messages are context, not conversation; unknown future kinds stay out.
|
|
175
|
-
if (event.type === 'user/message' && kind !== undefined && kind !== 'user')
|
|
176
|
-
return true;
|
|
177
|
-
if (kind !== undefined && EXCLUDED_RANGE_SOURCE_KINDS.has(kind))
|
|
178
|
-
return true;
|
|
179
|
-
return rangeContent(event).some(block => block.type === 'text' && COMPACTED_RANGE_TEXT.test(block.text.trim()));
|
|
180
|
-
}
|
|
181
|
-
function compactArguments(raw) {
|
|
182
|
-
if (typeof raw !== 'string') {
|
|
183
|
-
try {
|
|
184
|
-
return JSON.stringify(raw) ?? '';
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
return '';
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
try {
|
|
191
|
-
return JSON.stringify(JSON.parse(raw)) ?? raw;
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
return raw;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
function previewPart(text) {
|
|
198
|
-
// Code-point slice so a preview never ends on a lone surrogate.
|
|
199
|
-
return Array.from(text.replace(/\s+/gu, ' ').trim()).slice(0, CTX_EXPAND_PREVIEW_CHARS).join('');
|
|
200
|
-
}
|
|
201
|
-
function blockName(block) {
|
|
202
|
-
if (block.type !== 'image')
|
|
203
|
-
return block.type;
|
|
204
|
-
const image = block;
|
|
205
|
-
const detail = typeof image.attachment?.name === 'string'
|
|
206
|
-
? image.attachment.name
|
|
207
|
-
: typeof image.attachment?.mediaType === 'string'
|
|
208
|
-
? image.attachment.mediaType
|
|
209
|
-
: '';
|
|
210
|
-
return detail === '' ? 'image' : `image ${detail}`;
|
|
211
|
-
}
|
|
212
|
-
function rangeLabel(event, toolNames) {
|
|
213
|
-
if (event.type === 'user/message')
|
|
214
|
-
return 'user/message';
|
|
215
|
-
if (event.type === 'assistant/message')
|
|
216
|
-
return 'assistant/message';
|
|
217
|
-
const callId = String(event.data.message.toolCallId);
|
|
218
|
-
const name = toolNames.get(callId);
|
|
219
|
-
return name === undefined ? `tool/result (${callId})` : `tool/result ${name} (${callId})`;
|
|
220
|
-
}
|
|
221
|
-
/** Render one readable event as text; tool calls are text, never executable blocks. */
|
|
222
|
-
function renderRangeEvent(event, toolNames, verbose) {
|
|
223
|
-
const lines = [`[${String(event.seq)}] ${rangeLabel(event, toolNames)}`];
|
|
224
|
-
for (const block of rangeContent(event)) {
|
|
225
|
-
if (block.type === 'text') {
|
|
226
|
-
if (verbose) {
|
|
227
|
-
if (block.text !== '')
|
|
228
|
-
lines.push(` • [text] ${previewPart(block.text)}`);
|
|
229
|
-
}
|
|
230
|
-
else if (block.text !== '') {
|
|
231
|
-
lines.push(block.text);
|
|
232
|
-
}
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
if (block.type === 'tool-call') {
|
|
236
|
-
const call = String(block.id);
|
|
237
|
-
const identity = `${block.name} (${call})`;
|
|
238
|
-
const args = compactArguments(block.arguments);
|
|
239
|
-
if (verbose) {
|
|
240
|
-
lines.push(` • [tool-call] ${identity} ${previewPart(args)}`);
|
|
241
|
-
}
|
|
242
|
-
else {
|
|
243
|
-
lines.push(`[${String(event.seq)}] tool call ${identity}`);
|
|
244
|
-
lines.push(args);
|
|
245
|
-
}
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
// Reasoning is not transcript content; other non-text parts are named.
|
|
249
|
-
if (block.type === 'reasoning')
|
|
250
|
-
continue;
|
|
251
|
-
lines.push(verbose ? ` • [${blockName(block)}]` : `[${blockName(block)}]`);
|
|
252
|
-
}
|
|
253
|
-
return lines.join('\n');
|
|
254
|
-
}
|
|
255
|
-
function truncateToTokenBudget(text, maxTokens) {
|
|
256
|
-
if (maxTokens <= 0)
|
|
257
|
-
return '';
|
|
258
|
-
if (estimateTokens(text) <= maxTokens)
|
|
259
|
-
return text;
|
|
260
|
-
let low = 0;
|
|
261
|
-
let high = text.length;
|
|
262
|
-
while (low < high) {
|
|
263
|
-
const mid = Math.ceil((low + high) / 2);
|
|
264
|
-
if (estimateTokens(text.slice(0, mid)) <= maxTokens)
|
|
265
|
-
low = mid;
|
|
266
|
-
else
|
|
267
|
-
high = mid - 1;
|
|
268
|
-
}
|
|
269
|
-
let end = low;
|
|
270
|
-
// Do not split a UTF-16 surrogate pair at the budget boundary.
|
|
271
|
-
if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1] ?? ''))
|
|
272
|
-
end -= 1;
|
|
273
|
-
return text.slice(0, end);
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* Render selected readable events with the shared budget. `skipped` counts
|
|
277
|
-
* every in-bounds event that did not become a rendered section, including
|
|
278
|
-
* excluded types/source kinds and anything after an ordinary budget cut; seq
|
|
279
|
-
* gaps are not events and never count.
|
|
280
|
-
*/
|
|
281
|
-
function renderRangeTranscript(events, inBoundsCount, start, end, verbose, toolNames) {
|
|
282
|
-
const bodyBudget = CTX_EXPAND_RANGE_TOKEN_BUDGET - CTX_EXPAND_HEADER_RESERVE;
|
|
283
|
-
const sections = [];
|
|
284
|
-
const included = [];
|
|
285
|
-
let trailer = '';
|
|
286
|
-
for (const event of events) {
|
|
287
|
-
const section = renderRangeEvent(event, toolNames, verbose);
|
|
288
|
-
const candidate = [...sections, section].join('\n\n');
|
|
289
|
-
if (estimateTokens(candidate) <= bodyBudget) {
|
|
290
|
-
sections.push(section);
|
|
291
|
-
included.push(event.seq);
|
|
292
|
-
continue;
|
|
293
|
-
}
|
|
294
|
-
if (sections.length === 0) {
|
|
295
|
-
// One oversized event: keep a bounded head instead of returning an
|
|
296
|
-
// unbounded result. The continuation must not suggest start=<seq+1>,
|
|
297
|
-
// which would skip the unread remainder.
|
|
298
|
-
const marker = '[event truncated]';
|
|
299
|
-
const continuation = `Event ${String(event.seq)} truncated at the budget; narrow start/end to read the remainder (tag/message only restore dropped tool results).`;
|
|
300
|
-
const available = Math.max(1, bodyBudget - estimateTokens(marker) - estimateTokens(continuation) - 2);
|
|
301
|
-
sections.push(`${truncateToTokenBudget(section, available)}\n${marker}`);
|
|
302
|
-
included.push(event.seq);
|
|
303
|
-
trailer = continuation;
|
|
304
|
-
}
|
|
305
|
-
else {
|
|
306
|
-
const last = included[included.length - 1] ?? start;
|
|
307
|
-
const verbosity = verbose ? ' verbose=true' : '';
|
|
308
|
-
trailer = `Truncated at event ${String(last)} (budget: ~15000 estimated tokens). Call again with start=${String(last + 1)} end=${String(end)}${verbosity}.`;
|
|
309
|
-
}
|
|
310
|
-
break;
|
|
311
|
-
}
|
|
312
|
-
const skipped = Math.max(0, inBoundsCount - included.length);
|
|
313
|
-
const header = verbose
|
|
314
|
-
? `Events ${String(start)}-${String(end)} (verbose; ${String(included.length)} events, ${String(skipped)} skipped). Recover a full dropped tool result with tag or message.`
|
|
315
|
-
: `Events ${String(start)}-${String(end)} (${String(included.length)} events, ${String(skipped)} skipped):`;
|
|
316
|
-
return {
|
|
317
|
-
text: [header, ...sections, ...trailer === '' ? [] : [trailer]].join('\n\n'),
|
|
318
|
-
events: included,
|
|
319
|
-
skipped,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
export function defineCtxReduceTool(deps) {
|
|
323
|
-
const defineTool = deps.defineTool;
|
|
324
|
-
return defineTool({
|
|
325
|
-
name: 'ctx_reduce',
|
|
326
|
-
description: 'Mark conversation items you no longer need so their tokens are reclaimed. '
|
|
327
|
-
+ 'Pass tag IDs you saw on tool outputs: drop: "3-5, 1, 9". '
|
|
328
|
-
+ 'Marks are queued and land when the context manager opens its reclaim band; '
|
|
329
|
-
+ 'dropped tool outputs become `[dropped §N§]` placeholders. Nothing is lost: '
|
|
330
|
-
+ 'ctx_expand returns any original as a new result (the placeholder stays dropped).',
|
|
331
|
-
parameters: { drop: { type: 'string', required: true } },
|
|
332
|
-
output: {
|
|
333
|
-
schema: { type: 'json' },
|
|
334
|
-
render: (_args, value) => {
|
|
335
|
-
const payload = value;
|
|
336
|
-
const lines = [];
|
|
337
|
-
const queued = payload.queued ?? [];
|
|
338
|
-
// A count, not a list (MC 0.49.1 parity): the tags came from the agent's
|
|
339
|
-
// own surface, so echoing them back is pure noise, and the noise grows
|
|
340
|
-
// linearly with the batch. Refusals stay per-tag: each one is an
|
|
341
|
-
// actionable failure the agent must not retry blindly.
|
|
342
|
-
if (queued.length > 0) {
|
|
343
|
-
lines.push(`Queued ${String(queued.length)} ${queued.length === 1 ? 'tag' : 'tags'} for reclaim.`);
|
|
344
|
-
}
|
|
345
|
-
for (const refused of payload.refused ?? []) {
|
|
346
|
-
lines.push(`§${refused.tag}§ not queued: ${refused.reason ?? 'unavailable'}.`);
|
|
347
|
-
}
|
|
348
|
-
if (lines.length === 0)
|
|
349
|
-
lines.push('Nothing queued.');
|
|
350
|
-
return ackText(lines);
|
|
351
|
-
},
|
|
352
|
-
},
|
|
353
|
-
async execute(args, exec) {
|
|
354
|
-
const startedAt = Date.now();
|
|
355
|
-
const sessionId = sessionIdOf(exec);
|
|
356
|
-
if (sessionId === undefined)
|
|
357
|
-
return { ok: false, queued: [], refused: [], reason: 'no-session' };
|
|
358
|
-
const requested = parseTagRanges(args.drop);
|
|
359
|
-
const nodes = enumerateSurfaceToolResults(exec.agent.session, callId => deps.stores.getTag(sessionId, { kind: 'tool-result', callId }));
|
|
360
|
-
const queued = [];
|
|
361
|
-
const refused = [];
|
|
362
|
-
const now = Date.now();
|
|
363
|
-
for (const tag of requested) {
|
|
364
|
-
// Durable lifecycle first (#13 A): a spent tag stays refused even when
|
|
365
|
-
// its placeholder has left the live surface (historian publication), and
|
|
366
|
-
// a queued tag is refused explicitly as `already queued` rather than
|
|
367
|
-
// being written again.
|
|
368
|
-
const state = deps.stores.dropState(sessionId, tag);
|
|
369
|
-
if (state === 'landed') {
|
|
370
|
-
refused.push({ tag, ok: false, reason: 'already dropped' });
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
if (state === 'pending') {
|
|
374
|
-
refused.push({ tag, ok: false, reason: 'already queued' });
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
const node = nodes.find(candidate => candidate.tagNumber === tag);
|
|
378
|
-
if (node === undefined) {
|
|
379
|
-
refused.push({ tag, ok: false, reason: 'no such tool-output tag on the current surface' });
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
if (node.isPlaceholder) {
|
|
383
|
-
// A drop the emergency band performed never entered the queue, so its
|
|
384
|
-
// durable state is still `none`; the live placeholder is the record.
|
|
385
|
-
refused.push({ tag, ok: false, reason: 'already dropped' });
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
deps.stores.queueDrop(sessionId, tag, now);
|
|
389
|
-
queued.push(tag);
|
|
390
|
-
}
|
|
391
|
-
deps.journal?.info('tool-call', `ctx_reduce: queued ${String(queued.length)}, refused ${String(refused.length)}`, {
|
|
392
|
-
tool: 'ctx_reduce',
|
|
393
|
-
requested,
|
|
394
|
-
queued,
|
|
395
|
-
refused: refused.map(entry => ({ tag: entry.tag, reason: entry.reason })),
|
|
396
|
-
ms: Date.now() - startedAt,
|
|
397
|
-
}, sessionId);
|
|
398
|
-
return { ok: true, queued, refused };
|
|
399
|
-
},
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
export function defineCtxExpandTool(deps) {
|
|
403
|
-
const defineTool = deps.defineTool;
|
|
404
|
-
return defineTool({
|
|
405
|
-
name: 'ctx_expand',
|
|
406
|
-
description: 'Read or restore context. Locators (mutually exclusive): tag — or the deprecated tags alias — restores '
|
|
407
|
-
+ 'a dropped tool result as a new result; message restores one dropped tool result by DSH log event seq; '
|
|
408
|
-
+ 'start/end reads an inclusive range of DSH log events as a read-only transcript. '
|
|
409
|
-
+ 'Ranges use DSH log event seq, not upstream Magic Context message ordinals. '
|
|
410
|
-
+ 'A range does not require a prior drop and never changes drop state; verbose previews each event '
|
|
411
|
-
+ '(200 characters per part) instead of returning full bodies. '
|
|
412
|
-
+ 'For tag/message, the original content returns as this tool result; the old `[dropped §N§]` placeholder '
|
|
413
|
-
+ 'stays in place and its tag stays spent (drop the new result if you no longer need it).',
|
|
414
|
-
parameters: {
|
|
415
|
-
tag: { type: 'string', description: 'Canonical single tag, e.g. "3".' },
|
|
416
|
-
tags: { type: 'string', description: 'Deprecated multi-tag alias, e.g. "3, 7".' },
|
|
417
|
-
message: {
|
|
418
|
-
type: 'string',
|
|
419
|
-
description: 'DSH log event seq of a dropped tool/result or its `[dropped §N§]` placeholder; '
|
|
420
|
-
+ 'not the original Magic Context message number; `[compacted §C…§]` is not accepted.',
|
|
421
|
-
},
|
|
422
|
-
start: {
|
|
423
|
-
type: 'string',
|
|
424
|
-
description: 'Inclusive DSH log event seq range start (decimal string). Not an upstream message ordinal. '
|
|
425
|
-
+ 'Requires end; mutually exclusive with tag/tags/message.',
|
|
426
|
-
},
|
|
427
|
-
end: {
|
|
428
|
-
type: 'string',
|
|
429
|
-
description: 'Inclusive DSH log event seq range end (decimal string). Requires start.',
|
|
430
|
-
},
|
|
431
|
-
verbose: {
|
|
432
|
-
type: 'boolean',
|
|
433
|
-
description: 'Preview each range event (200 characters per part) instead of full bodies.',
|
|
434
|
-
},
|
|
435
|
-
},
|
|
436
|
-
output: {
|
|
437
|
-
schema: { type: 'json' },
|
|
438
|
-
render: (_args, value) => {
|
|
439
|
-
const payload = value;
|
|
440
|
-
const lines = [];
|
|
441
|
-
if (payload.reason !== undefined)
|
|
442
|
-
lines.push(`ctx_expand refused: ${payload.reason}.`);
|
|
443
|
-
for (const refused of payload.refused ?? []) {
|
|
444
|
-
lines.push(`§${refused.tag}§ not restored: ${refused.reason ?? 'unavailable'}.`);
|
|
445
|
-
}
|
|
446
|
-
const content = payload.content ?? [];
|
|
447
|
-
if (content.length > 0) {
|
|
448
|
-
// The payload IS the restored original. A partial multi-tag request
|
|
449
|
-
// (deprecated `tags`) appends its refusals after the payload so a
|
|
450
|
-
// successful single-tag result stays byte-identical to the original.
|
|
451
|
-
return lines.length === 0
|
|
452
|
-
? [...content]
|
|
453
|
-
: [...content, { type: 'text', text: `\n\n${lines.join('\n')}` }];
|
|
454
|
-
}
|
|
455
|
-
if (lines.length === 0)
|
|
456
|
-
lines.push('Nothing restored.');
|
|
457
|
-
return ackText(lines);
|
|
458
|
-
},
|
|
459
|
-
},
|
|
460
|
-
async execute(args, exec) {
|
|
461
|
-
const startedAt = Date.now();
|
|
462
|
-
const sessionId = sessionIdOf(exec);
|
|
463
|
-
if (sessionId === undefined)
|
|
464
|
-
return { ok: false, restored: [], refused: [], reason: 'no-session' };
|
|
465
|
-
const request = expandRequest(args);
|
|
466
|
-
if ('reason' in request) {
|
|
467
|
-
if (request.locator === 'range') {
|
|
468
|
-
deps.journal?.info('tool-call', 'ctx_expand: read 0 events (refused)', {
|
|
469
|
-
tool: 'ctx_expand',
|
|
470
|
-
locator: 'range',
|
|
471
|
-
start: args.start,
|
|
472
|
-
end: args.end,
|
|
473
|
-
verbose: args.verbose,
|
|
474
|
-
events: [],
|
|
475
|
-
skipped: 0,
|
|
476
|
-
reason: request.reason,
|
|
477
|
-
ms: Date.now() - startedAt,
|
|
478
|
-
}, sessionId);
|
|
479
|
-
return { ok: false, locator: 'range', reason: request.reason };
|
|
480
|
-
}
|
|
481
|
-
deps.journal?.info('tool-call', 'ctx_expand: restored 0, refused 0', {
|
|
482
|
-
tool: 'ctx_expand',
|
|
483
|
-
requested: [],
|
|
484
|
-
restored: [],
|
|
485
|
-
refused: [],
|
|
486
|
-
reason: request.reason,
|
|
487
|
-
ms: Date.now() - startedAt,
|
|
488
|
-
}, sessionId);
|
|
489
|
-
return { ok: false, restored: [], refused: [], reason: request.reason };
|
|
490
|
-
}
|
|
491
|
-
const session = exec.agent.session;
|
|
492
|
-
if (request.kind === 'range') {
|
|
493
|
-
const events = session.snapshotEvents();
|
|
494
|
-
const inBounds = events.filter(event => event.seq >= request.start && event.seq <= request.end);
|
|
495
|
-
const readable = inBounds
|
|
496
|
-
.filter(isRangeReadableEvent)
|
|
497
|
-
.filter(event => !isExcludedRangeEvent(event));
|
|
498
|
-
if (readable.length === 0) {
|
|
499
|
-
const reason = `no readable events in range ${String(request.start)}-${String(request.end)}`;
|
|
500
|
-
deps.journal?.info('tool-call', 'ctx_expand: read 0 events (no readable)', {
|
|
501
|
-
tool: 'ctx_expand',
|
|
502
|
-
locator: 'range',
|
|
503
|
-
start: request.start,
|
|
504
|
-
end: request.end,
|
|
505
|
-
verbose: request.verbose,
|
|
506
|
-
events: [],
|
|
507
|
-
skipped: inBounds.length,
|
|
508
|
-
reason,
|
|
509
|
-
ms: Date.now() - startedAt,
|
|
510
|
-
}, sessionId);
|
|
511
|
-
return { ok: false, locator: 'range', reason };
|
|
512
|
-
}
|
|
513
|
-
const live = new Set(session.surface.nodes);
|
|
514
|
-
if (readable.every(event => live.has(event.seq))) {
|
|
515
|
-
const text = `Events ${String(request.start)}-${String(request.end)} are already on the live surface; no raw history was read.`;
|
|
516
|
-
deps.journal?.info('tool-call', 'ctx_expand: read 0 events (live surface)', {
|
|
517
|
-
tool: 'ctx_expand',
|
|
518
|
-
locator: 'range',
|
|
519
|
-
start: request.start,
|
|
520
|
-
end: request.end,
|
|
521
|
-
verbose: request.verbose,
|
|
522
|
-
events: [],
|
|
523
|
-
skipped: inBounds.length - readable.length,
|
|
524
|
-
liveSurface: true,
|
|
525
|
-
ms: Date.now() - startedAt,
|
|
526
|
-
}, sessionId);
|
|
527
|
-
return { ok: true, locator: 'range', events: [], content: [{ type: 'text', text }] };
|
|
528
|
-
}
|
|
529
|
-
const toolNames = new Map();
|
|
530
|
-
for (const event of events) {
|
|
531
|
-
if (event.type === 'tool/call')
|
|
532
|
-
toolNames.set(event.data.callId, event.data.name);
|
|
533
|
-
}
|
|
534
|
-
const rendered = renderRangeTranscript(readable, inBounds.length, request.start, request.end, request.verbose, toolNames);
|
|
535
|
-
deps.journal?.info('tool-call', `ctx_expand: read ${String(rendered.events.length)} event(s)`, {
|
|
536
|
-
tool: 'ctx_expand',
|
|
537
|
-
locator: 'range',
|
|
538
|
-
start: request.start,
|
|
539
|
-
end: request.end,
|
|
540
|
-
verbose: request.verbose,
|
|
541
|
-
events: rendered.events,
|
|
542
|
-
skipped: rendered.skipped,
|
|
543
|
-
ms: Date.now() - startedAt,
|
|
544
|
-
}, sessionId);
|
|
545
|
-
return {
|
|
546
|
-
ok: true,
|
|
547
|
-
locator: 'range',
|
|
548
|
-
events: rendered.events,
|
|
549
|
-
content: [{ type: 'text', text: rendered.text }],
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
if (request.kind === 'message') {
|
|
553
|
-
const lookup = toolResultAt(session, request.message);
|
|
554
|
-
if (lookup.kind === 'unknown') {
|
|
555
|
-
deps.journal?.info('tool-call', 'ctx_expand: restored 0, refused 0', {
|
|
556
|
-
tool: 'ctx_expand',
|
|
557
|
-
requested: [],
|
|
558
|
-
message: request.message,
|
|
559
|
-
restored: [],
|
|
560
|
-
refused: [],
|
|
561
|
-
reason: 'no such message',
|
|
562
|
-
ms: Date.now() - startedAt,
|
|
563
|
-
}, sessionId);
|
|
564
|
-
return { ok: false, restored: [], refused: [], reason: 'no such message' };
|
|
565
|
-
}
|
|
566
|
-
if (lookup.kind === 'not-tool-result') {
|
|
567
|
-
deps.journal?.info('tool-call', 'ctx_expand: restored 0, refused 0', {
|
|
568
|
-
tool: 'ctx_expand',
|
|
569
|
-
requested: [],
|
|
570
|
-
message: request.message,
|
|
571
|
-
restored: [],
|
|
572
|
-
refused: [],
|
|
573
|
-
reason: 'not a tool-result message',
|
|
574
|
-
ms: Date.now() - startedAt,
|
|
575
|
-
}, sessionId);
|
|
576
|
-
return { ok: false, restored: [], refused: [], reason: 'not a tool-result message' };
|
|
577
|
-
}
|
|
578
|
-
const original = originalContentOfToolCall(session, lookup.callId);
|
|
579
|
-
const registeredTag = deps.stores.getTag(sessionId, { kind: 'tool-result', callId: lookup.callId });
|
|
580
|
-
// A message may point at the original event while the same call also
|
|
581
|
-
// has a placeholder; the call-level droppedTag is the evidence, not the
|
|
582
|
-
// shape of the event the caller named.
|
|
583
|
-
const callDroppedTag = original.kind === 'missing-event' ? undefined : original.droppedTag;
|
|
584
|
-
const tag = registeredTag ?? callDroppedTag;
|
|
585
|
-
if (tag === undefined) {
|
|
586
|
-
deps.journal?.info('tool-call', 'ctx_expand: restored 0, refused 0', {
|
|
587
|
-
tool: 'ctx_expand',
|
|
588
|
-
requested: [],
|
|
589
|
-
message: request.message,
|
|
590
|
-
restored: [],
|
|
591
|
-
refused: [],
|
|
592
|
-
reason: 'not a tool-result tag',
|
|
593
|
-
ms: Date.now() - startedAt,
|
|
594
|
-
}, sessionId);
|
|
595
|
-
return { ok: false, restored: [], refused: [], reason: 'not a tool-result tag' };
|
|
596
|
-
}
|
|
597
|
-
if (registeredTag === undefined) {
|
|
598
|
-
deps.journal?.info('tool-call', 'ctx_expand: restored 0, refused 0', {
|
|
599
|
-
tool: 'ctx_expand',
|
|
600
|
-
requested: [],
|
|
601
|
-
message: request.message,
|
|
602
|
-
restored: [],
|
|
603
|
-
refused: [],
|
|
604
|
-
reason: 'tag no longer available',
|
|
605
|
-
ms: Date.now() - startedAt,
|
|
606
|
-
}, sessionId);
|
|
607
|
-
return { ok: false, restored: [], refused: [], reason: 'tag no longer available' };
|
|
608
|
-
}
|
|
609
|
-
const outcome = restoreResolvedTag(deps, sessionId, tag, original);
|
|
610
|
-
if ('reason' in outcome) {
|
|
611
|
-
deps.journal?.info('tool-call', 'ctx_expand: restored 0, refused 0', {
|
|
612
|
-
tool: 'ctx_expand',
|
|
613
|
-
requested: [],
|
|
614
|
-
message: request.message,
|
|
615
|
-
restored: [],
|
|
616
|
-
refused: [],
|
|
617
|
-
reason: outcome.reason,
|
|
618
|
-
ms: Date.now() - startedAt,
|
|
619
|
-
}, sessionId);
|
|
620
|
-
return { ok: false, restored: [], refused: [], reason: outcome.reason };
|
|
621
|
-
}
|
|
622
|
-
deps.journal?.info('tool-call', 'ctx_expand: restored 1, refused 0', {
|
|
623
|
-
tool: 'ctx_expand',
|
|
624
|
-
requested: [],
|
|
625
|
-
message: request.message,
|
|
626
|
-
restored: [tag],
|
|
627
|
-
refused: [],
|
|
628
|
-
ms: Date.now() - startedAt,
|
|
629
|
-
}, sessionId);
|
|
630
|
-
return { ok: true, restored: [tag], refused: [], content: outcome.content };
|
|
631
|
-
}
|
|
632
|
-
// Resolve the tag's persisted call identity — no live placeholder is
|
|
633
|
-
// required, so a historian-published or restarted session reads the same
|
|
634
|
-
// original from the log (contract D1.4, E4).
|
|
635
|
-
const records = new Map(deps.stores.listTags(sessionId).map(record => [record.tagNumber, record]));
|
|
636
|
-
const restored = [];
|
|
637
|
-
const refused = [];
|
|
638
|
-
const payloads = [];
|
|
639
|
-
for (const tag of request.tags) {
|
|
640
|
-
const record = records.get(tag);
|
|
641
|
-
if (record === undefined) {
|
|
642
|
-
// Tag numbers are per-session (contract D5). The global index names a
|
|
643
|
-
// foreign owner only when this session has no tag records at all: once
|
|
644
|
-
// it holds any tag of its own, its numbering is live, so a number it
|
|
645
|
-
// never used is a typo, not a handle from another session.
|
|
646
|
-
const crossSession = records.size === 0
|
|
647
|
-
&& deps.stores.tagSessions(tag).some(other => other !== sessionId);
|
|
648
|
-
refused.push({ tag, ok: false, reason: crossSession ? 'cross-session tag' : 'no such tag' });
|
|
649
|
-
continue;
|
|
650
|
-
}
|
|
651
|
-
if (record.target.kind !== 'tool-result') {
|
|
652
|
-
refused.push({ tag, ok: false, reason: 'not a tool-result tag' });
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
655
|
-
const original = originalContentOfToolCall(session, record.target.callId);
|
|
656
|
-
const outcome = restoreResolvedTag(deps, sessionId, tag, original);
|
|
657
|
-
if ('reason' in outcome) {
|
|
658
|
-
refused.push({ tag, ok: false, reason: outcome.reason });
|
|
659
|
-
continue;
|
|
660
|
-
}
|
|
661
|
-
payloads.push(outcome.content);
|
|
662
|
-
restored.push(tag);
|
|
663
|
-
}
|
|
664
|
-
const content = joinExpandPayloads(payloads);
|
|
665
|
-
deps.journal?.info('tool-call', `ctx_expand: restored ${String(restored.length)}, refused ${String(refused.length)}`, {
|
|
666
|
-
tool: 'ctx_expand',
|
|
667
|
-
requested: request.tags,
|
|
668
|
-
restored,
|
|
669
|
-
refused: refused.map(entry => ({ tag: entry.tag, reason: entry.reason })),
|
|
670
|
-
ms: Date.now() - startedAt,
|
|
671
|
-
}, sessionId);
|
|
672
|
-
return { ok: true, restored, refused, ...content === undefined ? {} : { content } };
|
|
673
|
-
},
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
export function defineCtxMemoryTool(deps) {
|
|
677
|
-
const defineTool = deps.defineTool;
|
|
678
|
-
/**
|
|
679
|
-
* The session's project identity, which every write must carry: Magic Context
|
|
680
|
-
* only lets an agent mutate its own project's memories.
|
|
681
|
-
*/
|
|
682
|
-
const identityFor = (exec) => {
|
|
683
|
-
try {
|
|
684
|
-
return deps.resolveIdentity?.(exec);
|
|
685
|
-
}
|
|
686
|
-
catch {
|
|
687
|
-
return undefined;
|
|
688
|
-
}
|
|
689
|
-
};
|
|
690
|
-
return defineTool({
|
|
691
|
-
name: 'ctx_memory',
|
|
692
|
-
description: 'Read and maintain the project memory store (shared with the other coding agents on this machine): '
|
|
693
|
-
+ 'durable facts, constraints, configuration values and naming rules. '
|
|
694
|
-
+ 'Actions: list (optionally with query), search (hybrid keyword+semantic retrieval over the pool, '
|
|
695
|
-
+ 'optionally bounded by from/to dates), '
|
|
696
|
-
+ 'get (by id), write (new memory), update (rewrite one), '
|
|
697
|
-
+ 'archive (retire one, optionally superseded by another), merge (fold duplicates into one), '
|
|
698
|
-
+ 'review_global (present pending global-domain candidates — the work and global audiences — to the user as '
|
|
699
|
-
+ 'approval cards, one at a time; an approved candidate is published into the shared domain), '
|
|
700
|
-
+ 'promote (memory_id + optional domain machine|org — machine is the GLOBAL domain every project on '
|
|
701
|
-
+ 'this machine sees, org is the WORK domain limited to its member projects: enqueue one project memory '
|
|
702
|
-
+ 'into that domain and IMMEDIATELY raise its approval card — approved publishes, rejected retires it for '
|
|
703
|
-
+ 'good; the human-judged lane with no recurrence gate, so a single fresh write can be shared '
|
|
704
|
-
+ 'right away). '
|
|
705
|
-
+ 'Writes are scoped to this project and deduplicated by normalized content. '
|
|
706
|
-
+ 'Every action accepts an optional project (an absolute path or git root): use it when the '
|
|
707
|
-
+ 'memory belongs to ANOTHER project than the one this session was opened in — reading a '
|
|
708
|
-
+ "project's memories or writing facts about it while you work on it from a different session.",
|
|
709
|
-
parameters: {
|
|
710
|
-
action: { type: 'string', enum: ['list', 'search', 'get', 'write', 'update', 'archive', 'merge', 'review_global', 'promote'] },
|
|
711
|
-
query: { type: 'string' },
|
|
712
|
-
limit: { type: 'integer' },
|
|
713
|
-
from: { type: 'string' },
|
|
714
|
-
to: { type: 'string' },
|
|
715
|
-
id: { type: 'integer' },
|
|
716
|
-
ids: { type: 'array', items: { type: 'integer' } },
|
|
717
|
-
category: { type: 'string' },
|
|
718
|
-
content: { type: 'string' },
|
|
719
|
-
importance: { type: 'integer' },
|
|
720
|
-
reason: { type: 'string' },
|
|
721
|
-
superseded_by: { type: 'integer' },
|
|
722
|
-
target_id: { type: 'integer' },
|
|
723
|
-
project: { type: 'string' },
|
|
724
|
-
memory_id: { type: 'integer' },
|
|
725
|
-
domain: { type: 'string' },
|
|
726
|
-
},
|
|
727
|
-
output: {
|
|
728
|
-
schema: { type: 'json' },
|
|
729
|
-
render: (_args, value) => {
|
|
730
|
-
const payload = value;
|
|
731
|
-
if (payload.reason !== undefined)
|
|
732
|
-
return ackText([`Memory store unavailable: ${payload.reason}`]);
|
|
733
|
-
if (payload.search !== undefined)
|
|
734
|
-
return ackText([String(payload.search)]);
|
|
735
|
-
if (payload.outcome !== undefined) {
|
|
736
|
-
return ackText([payload.outcome, ...payload.detail === undefined ? [] : [payload.detail]]);
|
|
737
|
-
}
|
|
738
|
-
if (payload.memory !== undefined) {
|
|
739
|
-
return ackText([
|
|
740
|
-
`Memory ${String(payload.memory.id)} [${payload.memory.category}] (importance ${String(payload.memory.importance)}):`,
|
|
741
|
-
payload.memory.content,
|
|
742
|
-
]);
|
|
743
|
-
}
|
|
744
|
-
if (payload.empty === true || payload.lines === undefined || payload.lines.length === 0) {
|
|
745
|
-
return ackText(['No matching project memories.']);
|
|
746
|
-
}
|
|
747
|
-
return ackText([
|
|
748
|
-
`Project memories (${String(payload.lines.length)} shown):`,
|
|
749
|
-
...payload.lines,
|
|
750
|
-
]);
|
|
751
|
-
},
|
|
752
|
-
},
|
|
753
|
-
async execute(args, exec) {
|
|
754
|
-
const startedAt = Date.now();
|
|
755
|
-
const sessionId = sessionIdOf(exec);
|
|
756
|
-
const action = typeof args.action === 'string' ? args.action : 'list';
|
|
757
|
-
// Effective scope: the session's project unless the model names another
|
|
758
|
-
// one via `project`. An unresolvable spec is a structured refusal — the
|
|
759
|
-
// model then knows the write/read did NOT happen under some guessed scope.
|
|
760
|
-
const explicitProject = typeof args.project === 'string' && args.project.trim() !== ''
|
|
761
|
-
? args.project.trim()
|
|
762
|
-
: undefined;
|
|
763
|
-
let identity = identityFor(exec);
|
|
764
|
-
const finish = (payload) => {
|
|
765
|
-
const summary = payload.outcome
|
|
766
|
-
?? (payload.memory !== undefined
|
|
767
|
-
? `memory ${payload.memory.id}`
|
|
768
|
-
: payload.search !== undefined
|
|
769
|
-
? `search: ${String(payload.hits ?? 0)} result(s)`
|
|
770
|
-
: `${String(payload.lines?.length ?? 0)} line(s)`);
|
|
771
|
-
deps.journal?.info('tool-call', `ctx_memory ${action}: ${String(summary)}`, {
|
|
772
|
-
tool: 'ctx_memory',
|
|
773
|
-
action,
|
|
774
|
-
identity,
|
|
775
|
-
...explicitProject === undefined ? {} : { explicitProject },
|
|
776
|
-
query: args.query,
|
|
777
|
-
id: args.id,
|
|
778
|
-
// The panel's read row shows the hit count (`detail.hits`); without it
|
|
779
|
-
// a search record could only say "search".
|
|
780
|
-
...typeof payload.hits === 'number' ? { hits: payload.hits } : {},
|
|
781
|
-
ms: Date.now() - startedAt,
|
|
782
|
-
ok: typeof payload.ok === 'boolean' ? payload.ok : payload.reason === undefined,
|
|
783
|
-
}, sessionId);
|
|
784
|
-
return payload;
|
|
785
|
-
};
|
|
786
|
-
if (explicitProject !== undefined) {
|
|
787
|
-
let resolved;
|
|
788
|
-
if (explicitProject.startsWith('git:') || explicitProject.startsWith('dir:')) {
|
|
789
|
-
resolved = explicitProject;
|
|
790
|
-
}
|
|
791
|
-
else {
|
|
792
|
-
try {
|
|
793
|
-
resolved = deps.resolveIdentityOf?.(explicitProject);
|
|
794
|
-
}
|
|
795
|
-
catch {
|
|
796
|
-
resolved = undefined;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
if (resolved === undefined || resolved === '') {
|
|
800
|
-
return finish({
|
|
801
|
-
outcome: `project "${explicitProject}" could not be resolved to a project identity `
|
|
802
|
-
+ '(directory missing, or not inside a git repository and not an identity string). '
|
|
803
|
-
+ 'Nothing was read or written.',
|
|
804
|
-
ok: false,
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
identity = resolved;
|
|
808
|
-
}
|
|
809
|
-
if (action === 'review_global') {
|
|
810
|
-
if (deps.reviewGlobal === undefined) {
|
|
811
|
-
return finish({ reason: 'global review is not wired in this host' });
|
|
812
|
-
}
|
|
813
|
-
const result = await deps.reviewGlobal(exec);
|
|
814
|
-
return finish(result);
|
|
815
|
-
}
|
|
816
|
-
if (action === 'promote') {
|
|
817
|
-
if (deps.promoteGlobal === undefined) {
|
|
818
|
-
return finish({ reason: 'global promotion is not wired in this host' });
|
|
819
|
-
}
|
|
820
|
-
if (typeof args.memory_id !== 'number' || !Number.isInteger(args.memory_id)) {
|
|
821
|
-
return finish({
|
|
822
|
-
outcome: 'promote needs an integer memory_id (the project memory to enqueue for approval). '
|
|
823
|
-
+ 'Nothing was promoted.',
|
|
824
|
-
ok: false,
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined;
|
|
828
|
-
const result = await deps.promoteGlobal({ memoryId: args.memory_id, ...(domain === undefined ? {} : { domain }) }, exec);
|
|
829
|
-
return finish(result);
|
|
830
|
-
}
|
|
831
|
-
if (action === 'list' || (action === 'get' && args.id === undefined)) {
|
|
832
|
-
const recall = deps.recall;
|
|
833
|
-
if (recall === undefined) {
|
|
834
|
-
return finish({ reason: 'memory recall is not wired in this host' });
|
|
835
|
-
}
|
|
836
|
-
// The run context goes to the host alongside the request: a host whose
|
|
837
|
-
// recall callback scopes by `config.memory.project ?? session cwd` has
|
|
838
|
-
// no other way to see the session (index.ts), and `identity` is the
|
|
839
|
-
// identity this tool already resolved for the same purpose. The two
|
|
840
|
-
// model-supplied fields are narrowed here: `args` is the validated
|
|
841
|
-
// argument record, and a `query`/`limit` of the wrong type must not
|
|
842
|
-
// reach the host's recall as a non-string / non-number.
|
|
843
|
-
const query = typeof args.query === 'string' ? args.query : undefined;
|
|
844
|
-
const limit = typeof args.limit === 'number' ? args.limit : undefined;
|
|
845
|
-
const result = recall({
|
|
846
|
-
...query === undefined ? {} : { query },
|
|
847
|
-
...limit === undefined ? {} : { limit },
|
|
848
|
-
...identity === undefined ? {} : { identity },
|
|
849
|
-
}, exec);
|
|
850
|
-
if (result === undefined)
|
|
851
|
-
return finish({ empty: true });
|
|
852
|
-
// A store the host could not read at all (fenced schema, unreadable file)
|
|
853
|
-
// must say so: `{ empty: true }` would read as "your project has no
|
|
854
|
-
// memories", which is a different fact.
|
|
855
|
-
if (result.lines.length === 0 && result.reason !== undefined) {
|
|
856
|
-
return finish({ reason: result.reason });
|
|
857
|
-
}
|
|
858
|
-
// Retrieval telemetry (contract §9.1): the agent explicitly searched here,
|
|
859
|
-
// so the rows it received are counted. Telemetry is never correctness, so
|
|
860
|
-
// a fenced / authority-blocked / failing store is swallowed and the agent
|
|
861
|
-
// still gets its lines. An automatic injection path must NOT reach here.
|
|
862
|
-
const recalledIds = result.ids;
|
|
863
|
-
if (recalledIds !== undefined && recalledIds.length > 0 && identity !== undefined) {
|
|
864
|
-
try {
|
|
865
|
-
deps.writer?.recordRetrieval({ identity, ids: [...recalledIds] });
|
|
866
|
-
}
|
|
867
|
-
catch {
|
|
868
|
-
// never fail a read because a counter could not be bumped
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
return finish({ lines: [...result.lines], digest: result.digest });
|
|
872
|
-
}
|
|
873
|
-
if (action === 'search') {
|
|
874
|
-
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
875
|
-
if (query.length === 0)
|
|
876
|
-
return finish({ outcome: 'search needs a query.' });
|
|
877
|
-
if (deps.search === undefined) {
|
|
878
|
-
return finish({ reason: 'memory search is not wired in this host' });
|
|
879
|
-
}
|
|
880
|
-
if (identity === undefined) {
|
|
881
|
-
return finish({ reason: 'no project identity could be resolved for this session' });
|
|
882
|
-
}
|
|
883
|
-
// A3: `from`/`to` accept an ISO date (YYYY-MM-DD, local midnight /
|
|
884
|
-
// end-of-day) or epoch milliseconds; invalid values are ignored.
|
|
885
|
-
const parseBound = (raw, endOfDay) => {
|
|
886
|
-
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
887
|
-
return raw;
|
|
888
|
-
if (typeof raw !== 'string')
|
|
889
|
-
return undefined;
|
|
890
|
-
const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : raw);
|
|
891
|
-
if (Number.isNaN(ms))
|
|
892
|
-
return undefined;
|
|
893
|
-
return endOfDay ? ms + 24 * 3600 * 1000 - 1 : ms;
|
|
894
|
-
};
|
|
895
|
-
const from = parseBound(args.from, false);
|
|
896
|
-
const to = parseBound(args.to, true);
|
|
897
|
-
const visibleMemoryIds = deps.visibleMemoryIdsOf?.(exec);
|
|
898
|
-
const outcome = await deps.search({
|
|
899
|
-
query,
|
|
900
|
-
...args.limit === undefined ? {} : { limit: args.limit },
|
|
901
|
-
identity,
|
|
902
|
-
...visibleMemoryIds === undefined ? {} : { visibleMemoryIds },
|
|
903
|
-
...from === undefined ? {} : { from },
|
|
904
|
-
...to === undefined ? {} : { to },
|
|
905
|
-
});
|
|
906
|
-
if (!outcome.ok)
|
|
907
|
-
return finish({ reason: outcome.reason });
|
|
908
|
-
// Retrieval telemetry (contract §9.1): an explicit search counts, in
|
|
909
|
-
// MC only through ctx_search — the same rule, same call shape.
|
|
910
|
-
if (outcome.hits.length > 0) {
|
|
911
|
-
try {
|
|
912
|
-
deps.writer?.recordRetrieval({ identity, ids: outcome.hits.map(hit => hit.id) });
|
|
913
|
-
}
|
|
914
|
-
catch {
|
|
915
|
-
// never fail a read because a counter could not be bumped
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
return finish({ search: outcome.text, hits: outcome.hits.length });
|
|
919
|
-
}
|
|
920
|
-
// Every remaining action writes (or reads one row by id) and needs the
|
|
921
|
-
// writer plus a resolved identity.
|
|
922
|
-
const writer = deps.writer;
|
|
923
|
-
if (writer === undefined) {
|
|
924
|
-
return finish({ reason: 'memory writes are disabled (memory.enabled / memory.write)' });
|
|
925
|
-
}
|
|
926
|
-
if (identity === undefined) {
|
|
927
|
-
return finish({ reason: 'no project identity could be resolved for this session' });
|
|
928
|
-
}
|
|
929
|
-
if (action === 'get') {
|
|
930
|
-
const row = writer.getMemory(identity, args.id);
|
|
931
|
-
if (row === undefined)
|
|
932
|
-
return finish({ outcome: `No memory ${String(args.id)} in this project.` });
|
|
933
|
-
// Contract §9.1: a single-row get does NOT count as a retrieval — only
|
|
934
|
-
// explicit list/query searches do (MC search.ts:2258-2281: "a row read
|
|
935
|
-
// is not a search"). Pinned by memory-retrieval-wiring.spec.ts.
|
|
936
|
-
return finish({
|
|
937
|
-
memory: { id: row.id, category: row.category, content: row.content, importance: row.importance ?? 50 },
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
if (action === 'write') {
|
|
941
|
-
if (typeof args.content !== 'string' || typeof args.category !== 'string') {
|
|
942
|
-
return finish({ outcome: 'write needs both category and content.' });
|
|
943
|
-
}
|
|
944
|
-
const result = writer.writeMemory({
|
|
945
|
-
identity,
|
|
946
|
-
category: args.category,
|
|
947
|
-
content: args.content,
|
|
948
|
-
...args.importance === undefined ? {} : { importance: args.importance },
|
|
949
|
-
...sessionId === undefined ? {} : { sessionId },
|
|
950
|
-
});
|
|
951
|
-
if (!result.ok)
|
|
952
|
-
return finish({ outcome: `write refused (${result.reason}).`, detail: result.detail });
|
|
953
|
-
return finish({
|
|
954
|
-
outcome: result.created === false
|
|
955
|
-
? `Already known (memory ${String(result.id)}, seen ${String(result.seenCount ?? 0)} times).`
|
|
956
|
-
: `Saved as memory ${String(result.id)} [${args.category}].`,
|
|
957
|
-
});
|
|
958
|
-
}
|
|
959
|
-
if (action === 'update') {
|
|
960
|
-
if (args.id === undefined || typeof args.content !== 'string') {
|
|
961
|
-
return finish({ outcome: 'update needs id and content.' });
|
|
962
|
-
}
|
|
963
|
-
const result = writer.updateMemory({
|
|
964
|
-
identity,
|
|
965
|
-
id: args.id,
|
|
966
|
-
content: args.content,
|
|
967
|
-
...args.category === undefined ? {} : { category: args.category },
|
|
968
|
-
...args.importance === undefined ? {} : { importance: args.importance },
|
|
969
|
-
});
|
|
970
|
-
if (!result.ok)
|
|
971
|
-
return finish({ outcome: `update refused (${result.reason}).`, detail: result.detail });
|
|
972
|
-
return finish({ outcome: `Updated memory ${String(result.id)}.` });
|
|
973
|
-
}
|
|
974
|
-
if (action === 'archive') {
|
|
975
|
-
if (args.id === undefined)
|
|
976
|
-
return finish({ outcome: 'archive needs id.' });
|
|
977
|
-
const result = writer.archiveMemory({
|
|
978
|
-
identity,
|
|
979
|
-
id: args.id,
|
|
980
|
-
...args.reason === undefined ? {} : { reason: args.reason },
|
|
981
|
-
...args.superseded_by === undefined ? {} : { supersededBy: args.superseded_by },
|
|
982
|
-
});
|
|
983
|
-
if (!result.ok)
|
|
984
|
-
return finish({ outcome: `archive refused (${result.reason}).`, detail: result.detail });
|
|
985
|
-
return finish({ outcome: `Archived memory ${String(result.id)}.` });
|
|
986
|
-
}
|
|
987
|
-
if (action === 'merge') {
|
|
988
|
-
const target = (args.target_id ?? args.id);
|
|
989
|
-
const sources = (args.ids ?? []);
|
|
990
|
-
if (target === undefined || sources.length === 0 || typeof args.content !== 'string') {
|
|
991
|
-
return finish({ outcome: 'merge needs target_id, ids and the merged content.' });
|
|
992
|
-
}
|
|
993
|
-
const result = writer.mergeMemory({
|
|
994
|
-
identity,
|
|
995
|
-
targetId: target,
|
|
996
|
-
sourceIds: sources,
|
|
997
|
-
content: args.content,
|
|
998
|
-
...args.category === undefined ? {} : { category: args.category },
|
|
999
|
-
});
|
|
1000
|
-
if (!result.ok)
|
|
1001
|
-
return finish({ outcome: `merge refused (${result.reason}).`, detail: result.detail });
|
|
1002
|
-
return finish({
|
|
1003
|
-
outcome: `Merged ${String(result.superseded.length)} row(s) into memory ${String(result.id)}.`,
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
return finish({ outcome: `Unknown action "${action}".` });
|
|
1007
|
-
},
|
|
1008
|
-
});
|
|
1009
|
-
}
|