@possumtech/rummy 0.3.1 → 0.5.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/.env.example +12 -0
- package/FIDELITY_CONTRACT.md +172 -0
- package/README.md +5 -1
- package/SPEC.md +31 -17
- package/migrations/001_initial_schema.sql +3 -4
- package/package.json +1 -1
- package/src/agent/AgentLoop.js +51 -153
- package/src/agent/ContextAssembler.js +2 -0
- package/src/agent/KnownStore.js +16 -9
- package/src/agent/ResponseHealer.js +54 -1
- package/src/agent/TurnExecutor.js +125 -323
- package/src/agent/XmlParser.js +172 -42
- package/src/agent/known_queries.sql +1 -1
- package/src/agent/known_store.sql +29 -72
- package/src/agent/runs.sql +2 -2
- package/src/hooks/Hooks.js +1 -0
- package/src/hooks/PluginContext.js +8 -2
- package/src/hooks/RummyContext.js +6 -3
- package/src/hooks/ToolRegistry.js +29 -32
- package/src/plugins/ask_user/ask_user.js +2 -2
- package/src/plugins/ask_user/ask_userDoc.js +7 -10
- package/src/plugins/budget/README.md +28 -18
- package/src/plugins/budget/budget.js +80 -3
- package/src/plugins/budget/recovery.js +47 -0
- package/src/plugins/cp/cp.js +5 -5
- package/src/plugins/cp/cpDoc.js +1 -14
- package/src/plugins/engine/engine.sql +1 -1
- package/src/plugins/env/env.js +4 -4
- package/src/plugins/env/envDoc.js +4 -9
- package/src/plugins/file/file.js +2 -7
- package/src/plugins/get/get.js +32 -13
- package/src/plugins/get/getDoc.js +26 -44
- package/src/plugins/helpers.js +4 -4
- package/src/plugins/instructions/instructions.js +9 -7
- package/src/plugins/instructions/preamble.md +45 -26
- package/src/plugins/known/known.js +71 -15
- package/src/plugins/known/knownDoc.js +4 -20
- package/src/plugins/mv/mv.js +6 -6
- package/src/plugins/mv/mvDoc.js +4 -30
- package/src/plugins/policy/policy.js +47 -0
- package/src/plugins/previous/previous.js +10 -14
- package/src/plugins/progress/progress.js +29 -48
- package/src/plugins/prompt/prompt.js +18 -6
- package/src/plugins/rm/rm.js +4 -4
- package/src/plugins/rm/rmDoc.js +5 -14
- package/src/plugins/rpc/rpc.js +4 -2
- package/src/plugins/set/set.js +86 -91
- package/src/plugins/set/setDoc.js +28 -41
- package/src/plugins/sh/sh.js +4 -4
- package/src/plugins/sh/shDoc.js +4 -9
- package/src/plugins/skill/skill.js +2 -1
- package/src/plugins/summarize/summarize.js +9 -2
- package/src/plugins/summarize/summarizeDoc.js +10 -16
- package/src/plugins/telemetry/telemetry.js +36 -11
- package/src/plugins/think/think.js +13 -0
- package/src/plugins/think/thinkDoc.js +16 -0
- package/src/plugins/unknown/unknown.js +37 -9
- package/src/plugins/unknown/unknownDoc.js +7 -16
- package/src/plugins/update/update.js +9 -2
- package/src/plugins/update/updateDoc.js +12 -14
- package/src/server/ClientConnection.js +11 -1
- package/src/sql/functions/slugify.js +13 -1
- package/src/sql/v_model_context.sql +6 -6
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import RummyContext from "../hooks/RummyContext.js";
|
|
2
2
|
import ContextAssembler from "./ContextAssembler.js";
|
|
3
|
-
import KnownStore from "./KnownStore.js";
|
|
4
|
-
import msg from "./messages.js";
|
|
5
3
|
import ResponseHealer from "./ResponseHealer.js";
|
|
6
4
|
import { countTokens } from "./tokens.js";
|
|
7
5
|
import XmlParser from "./XmlParser.js";
|
|
8
6
|
|
|
7
|
+
const ACTION_SCHEMES = new Set([
|
|
8
|
+
"get",
|
|
9
|
+
"set",
|
|
10
|
+
"rm",
|
|
11
|
+
"mv",
|
|
12
|
+
"cp",
|
|
13
|
+
"sh",
|
|
14
|
+
"env",
|
|
15
|
+
"search",
|
|
16
|
+
]);
|
|
17
|
+
const MUTATION_SCHEMES = new Set(["set", "rm", "sh", "mv", "cp"]);
|
|
18
|
+
const READ_SCHEMES = new Set(["get", "env", "search"]);
|
|
19
|
+
|
|
9
20
|
export default class TurnExecutor {
|
|
10
21
|
#db;
|
|
11
22
|
#llmProvider;
|
|
@@ -54,7 +65,12 @@ export default class TurnExecutor {
|
|
|
54
65
|
fidelity: row.fidelity,
|
|
55
66
|
status: row.status,
|
|
56
67
|
body: projectedBody ?? "",
|
|
57
|
-
|
|
68
|
+
// Full-body token count, not projected. This is the cost to
|
|
69
|
+
// promote the entry — the number the model needs to do Token
|
|
70
|
+
// Budget math. Projecting the demoted symbol-preview (145
|
|
71
|
+
// tokens for a 2108-token file) was misleading the model into
|
|
72
|
+
// promotes that blew the Token Budget by 10-30× per entry.
|
|
73
|
+
tokens: countTokens(row.body ?? ""),
|
|
58
74
|
attributes: row.attributes,
|
|
59
75
|
category: row.category,
|
|
60
76
|
source_turn: row.turn,
|
|
@@ -65,6 +81,35 @@ export default class TurnExecutor {
|
|
|
65
81
|
run_id: runId,
|
|
66
82
|
});
|
|
67
83
|
const lastContextTokens = lastCtx?.context_tokens ?? 0;
|
|
84
|
+
|
|
85
|
+
// Baseline materialization — assemble with model's promoted spending
|
|
86
|
+
// removed (promoted data, promoted logging). The resulting size is the
|
|
87
|
+
// fixed overhead the model can't reduce without further demotion.
|
|
88
|
+
const baselineRows = rows.filter(
|
|
89
|
+
(r) =>
|
|
90
|
+
!(
|
|
91
|
+
(r.category === "data" || r.category === "logging") &&
|
|
92
|
+
r.fidelity === "promoted"
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
const baselineMessages = await ContextAssembler.assembleFromTurnContext(
|
|
96
|
+
baselineRows,
|
|
97
|
+
{
|
|
98
|
+
type: mode,
|
|
99
|
+
systemPrompt,
|
|
100
|
+
contextSize,
|
|
101
|
+
demoted,
|
|
102
|
+
toolSet,
|
|
103
|
+
lastContextTokens,
|
|
104
|
+
turn,
|
|
105
|
+
},
|
|
106
|
+
this.#hooks,
|
|
107
|
+
);
|
|
108
|
+
const baselineTokens = baselineMessages.reduce(
|
|
109
|
+
(sum, m) => sum + countTokens(m.content),
|
|
110
|
+
0,
|
|
111
|
+
);
|
|
112
|
+
|
|
68
113
|
const messages = await ContextAssembler.assembleFromTurnContext(
|
|
69
114
|
rows,
|
|
70
115
|
{
|
|
@@ -75,6 +120,7 @@ export default class TurnExecutor {
|
|
|
75
120
|
toolSet,
|
|
76
121
|
lastContextTokens,
|
|
77
122
|
turn,
|
|
123
|
+
baselineTokens,
|
|
78
124
|
},
|
|
79
125
|
this.#hooks,
|
|
80
126
|
);
|
|
@@ -117,13 +163,6 @@ export default class TurnExecutor {
|
|
|
117
163
|
sequence: turn,
|
|
118
164
|
});
|
|
119
165
|
|
|
120
|
-
const unresolved = await this.#knownStore.getUnresolved(currentRunId);
|
|
121
|
-
if (unresolved.length > 0) {
|
|
122
|
-
throw new Error(
|
|
123
|
-
msg("error.unresolved_proposed", { count: unresolved.length }),
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
166
|
// Build RummyContext before turn.started so plugins can write entries
|
|
128
167
|
const rummy = new RummyContext(
|
|
129
168
|
{
|
|
@@ -182,7 +221,7 @@ export default class TurnExecutor {
|
|
|
182
221
|
scheme: "instructions",
|
|
183
222
|
body: instrEntry[0]?.body || "",
|
|
184
223
|
attributes: instrAttrs,
|
|
185
|
-
fidelity: "
|
|
224
|
+
fidelity: "promoted",
|
|
186
225
|
category: "system",
|
|
187
226
|
});
|
|
188
227
|
|
|
@@ -235,7 +274,7 @@ export default class TurnExecutor {
|
|
|
235
274
|
await this.#knownStore.setFidelity(
|
|
236
275
|
currentRunId,
|
|
237
276
|
promptRow.path,
|
|
238
|
-
"
|
|
277
|
+
"demoted",
|
|
239
278
|
);
|
|
240
279
|
}
|
|
241
280
|
const reMat = await this.#materializeTurnContext({
|
|
@@ -284,10 +323,13 @@ export default class TurnExecutor {
|
|
|
284
323
|
}
|
|
285
324
|
}
|
|
286
325
|
|
|
326
|
+
const runRow = await this.#db.get_run_by_id.get({ id: currentRunId });
|
|
287
327
|
const filteredMessages = await this.#hooks.llm.messages.filter(messages, {
|
|
288
328
|
model: requestedModel,
|
|
289
329
|
projectId,
|
|
290
330
|
runId: currentRunId,
|
|
331
|
+
runAlias: runRow?.alias || `run_${currentRunId}`,
|
|
332
|
+
turn,
|
|
291
333
|
});
|
|
292
334
|
|
|
293
335
|
// Call LLM
|
|
@@ -297,6 +339,10 @@ export default class TurnExecutor {
|
|
|
297
339
|
/\b(503|429|timeout|ECONNREFUSED|ECONNRESET|unavailable)\b/i.test(
|
|
298
340
|
e.message,
|
|
299
341
|
);
|
|
342
|
+
const isContextExceeded = (e) =>
|
|
343
|
+
/\b(context.*(size|length|limit)|token.*(limit|exceed)|too.*(long|large))\b/i.test(
|
|
344
|
+
e.message,
|
|
345
|
+
);
|
|
300
346
|
|
|
301
347
|
for (let llmAttempt = 0; ; llmAttempt++) {
|
|
302
348
|
try {
|
|
@@ -315,6 +361,18 @@ export default class TurnExecutor {
|
|
|
315
361
|
await new Promise((r) => setTimeout(r, delay));
|
|
316
362
|
continue;
|
|
317
363
|
}
|
|
364
|
+
if (isContextExceeded(err)) {
|
|
365
|
+
console.warn(
|
|
366
|
+
`[RUMMY] LLM context exceeded: ${err.message.slice(0, 120)}. Returning 413.`,
|
|
367
|
+
);
|
|
368
|
+
return {
|
|
369
|
+
turn,
|
|
370
|
+
turnId: turnRow.id,
|
|
371
|
+
status: 413,
|
|
372
|
+
assembledTokens,
|
|
373
|
+
contextSize,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
318
376
|
throw err;
|
|
319
377
|
}
|
|
320
378
|
}
|
|
@@ -371,15 +429,7 @@ export default class TurnExecutor {
|
|
|
371
429
|
});
|
|
372
430
|
|
|
373
431
|
// --- PHASE 1: RECORD ---
|
|
374
|
-
// Split lifecycle signals from action commands.
|
|
375
|
-
// Lifecycle signals (summarize, update, unknown, known) are state
|
|
376
|
-
// declarations — always recorded, never 409'd by sequential dispatch.
|
|
377
|
-
const LIFECYCLE = new Set(["summarize", "update", "unknown", "known"]);
|
|
378
|
-
|
|
379
432
|
const recorded = [];
|
|
380
|
-
const lifecycle = [];
|
|
381
|
-
const actions = [];
|
|
382
|
-
|
|
383
433
|
for (const cmd of commands) {
|
|
384
434
|
const entry = await this.#record(
|
|
385
435
|
currentRunId,
|
|
@@ -388,33 +438,19 @@ export default class TurnExecutor {
|
|
|
388
438
|
mode,
|
|
389
439
|
cmd,
|
|
390
440
|
);
|
|
391
|
-
if (
|
|
392
|
-
recorded.push(entry);
|
|
393
|
-
|
|
394
|
-
if (LIFECYCLE.has(entry.scheme)) {
|
|
395
|
-
lifecycle.push(entry);
|
|
396
|
-
} else {
|
|
397
|
-
actions.push(entry);
|
|
398
|
-
}
|
|
441
|
+
if (entry) recorded.push(entry);
|
|
399
442
|
}
|
|
400
443
|
|
|
401
444
|
// --- PHASE 2: DISPATCH ---
|
|
445
|
+
// Sequential queue. Each tool completes before the next starts.
|
|
446
|
+
// On failure: abort remaining. On proposal: notify client, await
|
|
447
|
+
// resolution, continue.
|
|
402
448
|
let hasErrors = false;
|
|
403
|
-
let hasProposed = false;
|
|
404
449
|
let abortAfter = null;
|
|
405
|
-
const dispatched = [...lifecycle];
|
|
406
450
|
|
|
407
|
-
|
|
408
|
-
for (const entry of lifecycle) {
|
|
409
|
-
await this.#hooks.tool.before.emit({ entry, rummy });
|
|
410
|
-
await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
|
|
411
|
-
await this.#hooks.tool.after.emit({ entry, rummy });
|
|
412
|
-
await this.#hooks.entry.created.emit(entry);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
for (const entry of actions) {
|
|
451
|
+
for (const entry of recorded) {
|
|
416
452
|
if (abortAfter) {
|
|
417
|
-
const errorMsg = `Aborted — preceding <${abortAfter}>
|
|
453
|
+
const errorMsg = `Aborted — preceding <${abortAfter}> failed.`;
|
|
418
454
|
await this.#knownStore.upsert(
|
|
419
455
|
currentRunId,
|
|
420
456
|
turn,
|
|
@@ -431,35 +467,40 @@ export default class TurnExecutor {
|
|
|
431
467
|
await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
|
|
432
468
|
await this.#hooks.tool.after.emit({ entry, rummy });
|
|
433
469
|
await this.#hooks.entry.created.emit(entry);
|
|
434
|
-
dispatched.push(entry);
|
|
435
470
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
path: entry.resultPath || entry.path,
|
|
439
|
-
});
|
|
440
|
-
if (row?.status === 202) {
|
|
441
|
-
hasProposed = true;
|
|
442
|
-
abortAfter = entry.scheme;
|
|
443
|
-
} else if (row?.status >= 400) {
|
|
444
|
-
hasErrors = true;
|
|
445
|
-
abortAfter = entry.scheme;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
471
|
+
// Materialize proposals for this entry (set revisions → 202)
|
|
472
|
+
await this.#hooks.turn.proposing.emit({ rummy, recorded: [entry] });
|
|
448
473
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
474
|
+
// Check for any proposals created by this entry's dispatch
|
|
475
|
+
const proposed = await this.#knownStore.getUnresolved(currentRunId);
|
|
476
|
+
for (const p of proposed) {
|
|
477
|
+
await this.#hooks.turn.proposal.emit({
|
|
478
|
+
projectId,
|
|
479
|
+
run: currentAlias,
|
|
480
|
+
proposed: [p],
|
|
481
|
+
});
|
|
482
|
+
await this.#knownStore.waitForResolution(currentRunId, p.path);
|
|
483
|
+
const resolved = await this.#db.get_entry_state.get({
|
|
484
|
+
run_id: currentRunId,
|
|
485
|
+
path: p.path,
|
|
486
|
+
});
|
|
487
|
+
if (resolved?.status >= 400) {
|
|
488
|
+
hasErrors = true;
|
|
489
|
+
abortAfter = entry.scheme;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
453
492
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
493
|
+
// Also check the entry itself for direct failures
|
|
494
|
+
if (!hasErrors) {
|
|
495
|
+
const entryPath = entry.resultPath || entry.path;
|
|
457
496
|
const row = await this.#db.get_entry_state.get({
|
|
458
497
|
run_id: currentRunId,
|
|
459
|
-
path:
|
|
498
|
+
path: entryPath,
|
|
460
499
|
});
|
|
461
|
-
if (row?.status
|
|
462
|
-
|
|
500
|
+
if (row?.status >= 400) {
|
|
501
|
+
hasErrors = true;
|
|
502
|
+
abortAfter = entry.scheme;
|
|
503
|
+
}
|
|
463
504
|
}
|
|
464
505
|
}
|
|
465
506
|
|
|
@@ -468,8 +509,7 @@ export default class TurnExecutor {
|
|
|
468
509
|
// budget recovery phase before continuing.
|
|
469
510
|
let budgetRecovery = null;
|
|
470
511
|
// Use actual prompt_tokens from this turn's LLM response as the ground-truth
|
|
471
|
-
//
|
|
472
|
-
const currentPromptTokens = result.usage?.prompt_tokens ?? 0;
|
|
512
|
+
// Post-dispatch budget check — demotion handled by budget plugin
|
|
473
513
|
if (contextSize) {
|
|
474
514
|
const postMat = await this.#materializeTurnContext({
|
|
475
515
|
runId: currentRunId,
|
|
@@ -481,92 +521,31 @@ export default class TurnExecutor {
|
|
|
481
521
|
contextSize,
|
|
482
522
|
demoted,
|
|
483
523
|
});
|
|
484
|
-
|
|
524
|
+
budgetRecovery = await this.#hooks.budget.postDispatch({
|
|
485
525
|
contextSize,
|
|
486
526
|
messages: postMat.messages,
|
|
487
527
|
rows: postMat.rows,
|
|
488
|
-
|
|
528
|
+
runId: currentRunId,
|
|
529
|
+
loopId: currentLoopId,
|
|
530
|
+
turn,
|
|
531
|
+
db: this.#db,
|
|
532
|
+
store: this.#knownStore,
|
|
489
533
|
});
|
|
490
|
-
if (postBudget.status === 413) {
|
|
491
|
-
// Demote this turn's data entries.
|
|
492
|
-
const demotedEntries = await this.#db.demote_turn_data_entries.all({
|
|
493
|
-
run_id: currentRunId,
|
|
494
|
-
turn,
|
|
495
|
-
});
|
|
496
|
-
const paths = demotedEntries.map((r) => r.path).join(", ");
|
|
497
|
-
|
|
498
|
-
// Also summarize the prompt — forces the model to earn it back.
|
|
499
|
-
const promptRow = postMat.rows.find((r) => r.scheme === "prompt");
|
|
500
|
-
if (promptRow) {
|
|
501
|
-
await this.#knownStore.setFidelity(
|
|
502
|
-
currentRunId,
|
|
503
|
-
promptRow.path,
|
|
504
|
-
"summary",
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Re-materialize after both demotions for accurate token count.
|
|
509
|
-
const recoveryMat = await this.#materializeTurnContext({
|
|
510
|
-
runId: currentRunId,
|
|
511
|
-
loopId: currentLoopId,
|
|
512
|
-
turn,
|
|
513
|
-
systemPrompt,
|
|
514
|
-
mode,
|
|
515
|
-
toolSet: effectiveToolSet,
|
|
516
|
-
contextSize,
|
|
517
|
-
demoted,
|
|
518
|
-
});
|
|
519
|
-
const recoveryBudget = await this.#hooks.budget.enforce({
|
|
520
|
-
contextSize,
|
|
521
|
-
messages: recoveryMat.messages,
|
|
522
|
-
rows: recoveryMat.rows,
|
|
523
|
-
lastPromptTokens: currentPromptTokens,
|
|
524
|
-
});
|
|
525
|
-
const safeLevel = Math.floor(contextSize * 0.9);
|
|
526
|
-
const tokensToFree = Math.max(
|
|
527
|
-
0,
|
|
528
|
-
recoveryBudget.assembledTokens - safeLevel,
|
|
529
|
-
);
|
|
530
|
-
|
|
531
|
-
const promptLine =
|
|
532
|
-
tokensToFree > 0
|
|
533
|
-
? `Info: Prompt auto-summarized. Full prompt restores automatically when you free ${tokensToFree} tokens.`
|
|
534
|
-
: "Info: Prompt auto-summarized. It will restore automatically.";
|
|
535
|
-
const body = [
|
|
536
|
-
"Error 413: Context Size Exceeded",
|
|
537
|
-
"",
|
|
538
|
-
"Required: YOU MUST demote larger and/or less relevant items to optimize your context.",
|
|
539
|
-
`Info: ${paths} have been automatically summarized to avoid overflow.`,
|
|
540
|
-
promptLine,
|
|
541
|
-
"Info: YOU MAY use bulk patterns to demote and promote entries by pattern.",
|
|
542
|
-
"Info: Well-designed paths and summaries improve context management.",
|
|
543
|
-
'Example: <set path="known://people/*" fidelity="summary"/>',
|
|
544
|
-
].join("\n");
|
|
545
|
-
|
|
546
|
-
await this.#knownStore.upsert(
|
|
547
|
-
currentRunId,
|
|
548
|
-
turn,
|
|
549
|
-
`budget://${currentLoopId}/${turn}`,
|
|
550
|
-
body,
|
|
551
|
-
413,
|
|
552
|
-
{ loopId: currentLoopId },
|
|
553
|
-
);
|
|
554
|
-
|
|
555
|
-
budgetRecovery = {
|
|
556
|
-
target: safeLevel,
|
|
557
|
-
promptPath: promptRow?.path ?? null,
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
534
|
}
|
|
561
535
|
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
const updateEntry = lifecycle.find((e) => e.scheme === "update");
|
|
536
|
+
const summaryEntry = recorded.findLast((e) => e.scheme === "summarize");
|
|
537
|
+
const updateEntry = recorded.findLast((e) => e.scheme === "update");
|
|
565
538
|
let summaryText = summaryEntry?.body || null;
|
|
566
539
|
let updateText = updateEntry?.body || null;
|
|
567
540
|
|
|
568
|
-
// If model sent both,
|
|
569
|
-
if (summaryText && updateText)
|
|
541
|
+
// If model sent both, last signal wins — respects the model's final intent
|
|
542
|
+
if (summaryText && updateText) {
|
|
543
|
+
const lastLifecycle = recorded.findLast(
|
|
544
|
+
(e) => e.scheme === "summarize" || e.scheme === "update",
|
|
545
|
+
);
|
|
546
|
+
if (lastLifecycle.scheme === "summarize") updateText = null;
|
|
547
|
+
else summaryText = null;
|
|
548
|
+
}
|
|
570
549
|
|
|
571
550
|
// If model says "done" but actions failed, override — the model's
|
|
572
551
|
// assertion that it's done is false if it failed to do what it tried.
|
|
@@ -598,11 +577,7 @@ export default class TurnExecutor {
|
|
|
598
577
|
|
|
599
578
|
// --- Classify for return value ---
|
|
600
579
|
|
|
601
|
-
const actionCalls = recorded.filter((e) =>
|
|
602
|
-
["get", "set", "rm", "mv", "cp", "sh", "env", "search"].includes(
|
|
603
|
-
e.scheme,
|
|
604
|
-
),
|
|
605
|
-
);
|
|
580
|
+
const actionCalls = recorded.filter((e) => ACTION_SCHEMES.has(e.scheme));
|
|
606
581
|
const writeCalls = recorded.filter(
|
|
607
582
|
(e) =>
|
|
608
583
|
e.scheme === "known" ||
|
|
@@ -610,12 +585,8 @@ export default class TurnExecutor {
|
|
|
610
585
|
);
|
|
611
586
|
const unknownCalls = recorded.filter((e) => e.scheme === "unknown");
|
|
612
587
|
|
|
613
|
-
const hasAct = actionCalls.some((c) =>
|
|
614
|
-
|
|
615
|
-
);
|
|
616
|
-
const hasReads = actionCalls.some((c) =>
|
|
617
|
-
["get", "env", "search"].includes(c.scheme),
|
|
618
|
-
);
|
|
588
|
+
const hasAct = actionCalls.some((c) => MUTATION_SCHEMES.has(c.scheme));
|
|
589
|
+
const hasReads = actionCalls.some((c) => READ_SCHEMES.has(c.scheme));
|
|
619
590
|
const hasWrites = writeCalls.length > 0 || unknownCalls.length > 0;
|
|
620
591
|
const flags = { hasAct, hasReads, hasWrites };
|
|
621
592
|
|
|
@@ -651,83 +622,7 @@ export default class TurnExecutor {
|
|
|
651
622
|
* Returns the recorded entry descriptor, or null if rejected/skipped.
|
|
652
623
|
*/
|
|
653
624
|
async #record(runId, loopId, turn, mode, cmd) {
|
|
654
|
-
if (mode === "ask") {
|
|
655
|
-
if (cmd.name === "sh") {
|
|
656
|
-
console.warn("[RUMMY] Rejected <sh> in ask mode");
|
|
657
|
-
return null;
|
|
658
|
-
}
|
|
659
|
-
if (cmd.name === "set" && cmd.path && cmd.body) {
|
|
660
|
-
const scheme = KnownStore.scheme(cmd.path);
|
|
661
|
-
if (scheme === null) {
|
|
662
|
-
console.warn(
|
|
663
|
-
`[RUMMY] Rejected file edit to ${cmd.path} in ${mode} mode`,
|
|
664
|
-
);
|
|
665
|
-
return null;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
if (cmd.name === "rm" && cmd.path) {
|
|
669
|
-
const scheme = KnownStore.scheme(cmd.path);
|
|
670
|
-
if (scheme === null) {
|
|
671
|
-
console.warn(`[RUMMY] Rejected file rm of ${cmd.path} in ask mode`);
|
|
672
|
-
return null;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
if ((cmd.name === "mv" || cmd.name === "cp") && cmd.to) {
|
|
676
|
-
const destScheme = KnownStore.scheme(cmd.to);
|
|
677
|
-
if (destScheme === null) {
|
|
678
|
-
console.warn(
|
|
679
|
-
`[RUMMY] Rejected ${cmd.name} to file ${cmd.to} in ask mode`,
|
|
680
|
-
);
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
625
|
const scheme = cmd.name;
|
|
687
|
-
|
|
688
|
-
// Structural tags — recorded like any other entry
|
|
689
|
-
if (scheme === "summarize" || scheme === "update") {
|
|
690
|
-
const statusPath = await this.#knownStore.slugPath(
|
|
691
|
-
runId,
|
|
692
|
-
scheme,
|
|
693
|
-
cmd.body,
|
|
694
|
-
);
|
|
695
|
-
await this.#knownStore.upsert(runId, turn, statusPath, cmd.body, 200, {
|
|
696
|
-
loopId,
|
|
697
|
-
});
|
|
698
|
-
return {
|
|
699
|
-
scheme,
|
|
700
|
-
body: cmd.body,
|
|
701
|
-
path: statusPath,
|
|
702
|
-
resultPath: statusPath,
|
|
703
|
-
attributes: null,
|
|
704
|
-
};
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// Unknown — deduplicated, sticky
|
|
708
|
-
if (scheme === "unknown") {
|
|
709
|
-
const existingValues = await this.#knownStore.getUnknownValues(runId);
|
|
710
|
-
if (existingValues.has(cmd.body)) {
|
|
711
|
-
console.warn(`[RUMMY] Unknown deduped: "${cmd.body.slice(0, 60)}"`);
|
|
712
|
-
return null;
|
|
713
|
-
}
|
|
714
|
-
const unknownPath = await this.#knownStore.slugPath(
|
|
715
|
-
runId,
|
|
716
|
-
"unknown",
|
|
717
|
-
cmd.body,
|
|
718
|
-
);
|
|
719
|
-
await this.#knownStore.upsert(runId, turn, unknownPath, cmd.body, 200, {
|
|
720
|
-
loopId,
|
|
721
|
-
});
|
|
722
|
-
return {
|
|
723
|
-
scheme,
|
|
724
|
-
path: unknownPath,
|
|
725
|
-
body: cmd.body,
|
|
726
|
-
resultPath: unknownPath,
|
|
727
|
-
attributes: null,
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
|
|
731
626
|
const rawTarget = cmd.path || cmd.command || cmd.question || "";
|
|
732
627
|
// Reject paths that are likely reasoning bleed — too long or contain non-printing chars
|
|
733
628
|
if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
|
|
@@ -766,108 +661,15 @@ export default class TurnExecutor {
|
|
|
766
661
|
const { name: _, ...attributes } = cmd;
|
|
767
662
|
if (cmd.path) attributes.path = target;
|
|
768
663
|
|
|
769
|
-
// known tool or naked write → known:// slug from body
|
|
770
|
-
if (scheme === "known" || (scheme === "set" && !cmd.path)) {
|
|
771
|
-
if (!cmd.body) return null;
|
|
772
|
-
|
|
773
|
-
// Size gate: reject entries > 512 tokens — force atomic entries
|
|
774
|
-
const entryTokens = countTokens(cmd.body);
|
|
775
|
-
const MAX_ENTRY_TOKENS = 512;
|
|
776
|
-
if (scheme === "known" && entryTokens > MAX_ENTRY_TOKENS) {
|
|
777
|
-
const rejectPath = await this.#knownStore.slugPath(
|
|
778
|
-
runId,
|
|
779
|
-
scheme,
|
|
780
|
-
cmd.body,
|
|
781
|
-
);
|
|
782
|
-
await this.#knownStore.upsert(
|
|
783
|
-
runId,
|
|
784
|
-
turn,
|
|
785
|
-
rejectPath,
|
|
786
|
-
`Entry too large (${entryTokens} tokens, max ${MAX_ENTRY_TOKENS}). Sort the information, ideas, or plans carefully into multiple entries.`,
|
|
787
|
-
413,
|
|
788
|
-
{ loopId },
|
|
789
|
-
);
|
|
790
|
-
return {
|
|
791
|
-
scheme,
|
|
792
|
-
path: rejectPath,
|
|
793
|
-
body: "",
|
|
794
|
-
resultPath: rejectPath,
|
|
795
|
-
attributes,
|
|
796
|
-
status: 413,
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
let knownPath = cmd.path;
|
|
801
|
-
if (!knownPath) {
|
|
802
|
-
knownPath = await this.#knownStore.slugPath(
|
|
803
|
-
runId,
|
|
804
|
-
"known",
|
|
805
|
-
cmd.body,
|
|
806
|
-
cmd.summary,
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
// Dedup: if this exact path already exists, update rather than duplicate
|
|
810
|
-
const existing = await this.#knownStore.getEntriesByPattern(
|
|
811
|
-
runId,
|
|
812
|
-
knownPath,
|
|
813
|
-
null,
|
|
814
|
-
);
|
|
815
|
-
if (existing.length > 0) {
|
|
816
|
-
// Path exists — update body and turn, skip creating a new entry
|
|
817
|
-
await this.#knownStore.upsert(
|
|
818
|
-
runId,
|
|
819
|
-
turn,
|
|
820
|
-
existing[0].path,
|
|
821
|
-
cmd.body || existing[0].body,
|
|
822
|
-
200,
|
|
823
|
-
{
|
|
824
|
-
attributes,
|
|
825
|
-
loopId,
|
|
826
|
-
},
|
|
827
|
-
);
|
|
828
|
-
return {
|
|
829
|
-
scheme: "known",
|
|
830
|
-
path: existing[0].path,
|
|
831
|
-
body: cmd.body || existing[0].body,
|
|
832
|
-
resultPath: existing[0].path,
|
|
833
|
-
attributes,
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
await this.#knownStore.upsert(runId, turn, knownPath, cmd.body, 200, {
|
|
837
|
-
attributes,
|
|
838
|
-
loopId,
|
|
839
|
-
});
|
|
840
|
-
return {
|
|
841
|
-
scheme: "known",
|
|
842
|
-
path: knownPath,
|
|
843
|
-
body: cmd.body,
|
|
844
|
-
resultPath: knownPath,
|
|
845
|
-
attributes,
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
|
|
849
664
|
const body = cmd.body || cmd.command || cmd.question || "";
|
|
850
665
|
|
|
851
666
|
// Filter: plugins can validate/transform before recording
|
|
852
667
|
const filtered = await this.#hooks.entry.recording.filter(
|
|
853
668
|
{ scheme, path: resultPath, body, attributes, status: 200 },
|
|
854
|
-
{ runId, turn, loopId },
|
|
669
|
+
{ runId, turn, loopId, mode },
|
|
855
670
|
);
|
|
856
671
|
if (filtered.status >= 400) return filtered;
|
|
857
672
|
|
|
858
|
-
// Record the entry — 200 OK, handlers change status during dispatch
|
|
859
|
-
await this.#knownStore.upsert(
|
|
860
|
-
runId,
|
|
861
|
-
turn,
|
|
862
|
-
filtered.path,
|
|
863
|
-
filtered.body,
|
|
864
|
-
200,
|
|
865
|
-
{
|
|
866
|
-
attributes: filtered.attributes,
|
|
867
|
-
loopId,
|
|
868
|
-
},
|
|
869
|
-
);
|
|
870
|
-
|
|
871
673
|
return {
|
|
872
674
|
scheme: filtered.scheme,
|
|
873
675
|
path: filtered.path,
|