@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.
Files changed (63) hide show
  1. package/.env.example +12 -0
  2. package/FIDELITY_CONTRACT.md +172 -0
  3. package/README.md +5 -1
  4. package/SPEC.md +31 -17
  5. package/migrations/001_initial_schema.sql +3 -4
  6. package/package.json +1 -1
  7. package/src/agent/AgentLoop.js +51 -153
  8. package/src/agent/ContextAssembler.js +2 -0
  9. package/src/agent/KnownStore.js +16 -9
  10. package/src/agent/ResponseHealer.js +54 -1
  11. package/src/agent/TurnExecutor.js +125 -323
  12. package/src/agent/XmlParser.js +172 -42
  13. package/src/agent/known_queries.sql +1 -1
  14. package/src/agent/known_store.sql +29 -72
  15. package/src/agent/runs.sql +2 -2
  16. package/src/hooks/Hooks.js +1 -0
  17. package/src/hooks/PluginContext.js +8 -2
  18. package/src/hooks/RummyContext.js +6 -3
  19. package/src/hooks/ToolRegistry.js +29 -32
  20. package/src/plugins/ask_user/ask_user.js +2 -2
  21. package/src/plugins/ask_user/ask_userDoc.js +7 -10
  22. package/src/plugins/budget/README.md +28 -18
  23. package/src/plugins/budget/budget.js +80 -3
  24. package/src/plugins/budget/recovery.js +47 -0
  25. package/src/plugins/cp/cp.js +5 -5
  26. package/src/plugins/cp/cpDoc.js +1 -14
  27. package/src/plugins/engine/engine.sql +1 -1
  28. package/src/plugins/env/env.js +4 -4
  29. package/src/plugins/env/envDoc.js +4 -9
  30. package/src/plugins/file/file.js +2 -7
  31. package/src/plugins/get/get.js +32 -13
  32. package/src/plugins/get/getDoc.js +26 -44
  33. package/src/plugins/helpers.js +4 -4
  34. package/src/plugins/instructions/instructions.js +9 -7
  35. package/src/plugins/instructions/preamble.md +45 -26
  36. package/src/plugins/known/known.js +71 -15
  37. package/src/plugins/known/knownDoc.js +4 -20
  38. package/src/plugins/mv/mv.js +6 -6
  39. package/src/plugins/mv/mvDoc.js +4 -30
  40. package/src/plugins/policy/policy.js +47 -0
  41. package/src/plugins/previous/previous.js +10 -14
  42. package/src/plugins/progress/progress.js +29 -48
  43. package/src/plugins/prompt/prompt.js +18 -6
  44. package/src/plugins/rm/rm.js +4 -4
  45. package/src/plugins/rm/rmDoc.js +5 -14
  46. package/src/plugins/rpc/rpc.js +4 -2
  47. package/src/plugins/set/set.js +86 -91
  48. package/src/plugins/set/setDoc.js +28 -41
  49. package/src/plugins/sh/sh.js +4 -4
  50. package/src/plugins/sh/shDoc.js +4 -9
  51. package/src/plugins/skill/skill.js +2 -1
  52. package/src/plugins/summarize/summarize.js +9 -2
  53. package/src/plugins/summarize/summarizeDoc.js +10 -16
  54. package/src/plugins/telemetry/telemetry.js +36 -11
  55. package/src/plugins/think/think.js +13 -0
  56. package/src/plugins/think/thinkDoc.js +16 -0
  57. package/src/plugins/unknown/unknown.js +37 -9
  58. package/src/plugins/unknown/unknownDoc.js +7 -16
  59. package/src/plugins/update/update.js +9 -2
  60. package/src/plugins/update/updateDoc.js +12 -14
  61. package/src/server/ClientConnection.js +11 -1
  62. package/src/sql/functions/slugify.js +13 -1
  63. 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
- tokens: countTokens(projectedBody ?? ""),
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: "full",
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
- "summary",
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 (!entry) continue;
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
- // Lifecycle signals first always dispatched, never aborted.
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}> requires resolution.`;
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
- const row = await this.#db.get_entry_state.get({
437
- run_id: currentRunId,
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
- // Materialize proposals only if we dispatched actions
450
- if (!abortAfter || hasProposed) {
451
- await this.#hooks.turn.proposing.emit({ rummy, recorded: dispatched });
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
- // Recheck after materialization (set handler may create proposals)
455
- if (!hasProposed && !hasErrors) {
456
- for (const entry of actions) {
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: entry.resultPath || entry.path,
498
+ path: entryPath,
460
499
  });
461
- if (row?.status === 202) hasProposed = true;
462
- if (row?.status >= 400) hasErrors = true;
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
- // token count for post-turn budget checksmore accurate than the estimate.
472
- const currentPromptTokens = result.usage?.prompt_tokens ?? 0;
512
+ // Post-dispatch budget checkdemotion 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
- const postBudget = await this.#hooks.budget.enforce({
524
+ budgetRecovery = await this.#hooks.budget.postDispatch({
485
525
  contextSize,
486
526
  messages: postMat.messages,
487
527
  rows: postMat.rows,
488
- lastPromptTokens: currentPromptTokens,
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
- // Lifecycle signals are always available never 409'd.
563
- const summaryEntry = lifecycle.find((e) => e.scheme === "summarize");
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, update wins — if it can't decide, it's not done
569
- if (summaryText && updateText) summaryText = null;
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
- ["set", "rm", "sh", "mv", "cp"].includes(c.scheme),
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,