volute 0.51.0 → 0.53.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 (112) hide show
  1. package/dist/{accept-5NK5XJB5.js → accept-I4GVGWA3.js} +6 -3
  2. package/dist/{auth-X6EMNEGB.js → auth-KRDSM55Q.js} +3 -1
  3. package/dist/{chat-VAZ25KVT.js → chat-WK2BLM7B.js} +9 -9
  4. package/dist/{chunk-VAEZCIJC.js → chunk-23UJGVYP.js} +9 -9
  5. package/dist/{chunk-VSBMLB2W.js → chunk-5DBGQBQR.js} +138 -13
  6. package/dist/chunk-5RBEEAKW.js +58 -0
  7. package/dist/{chunk-BSHZ23MS.js → chunk-7ARONCZW.js} +25 -3
  8. package/dist/{chunk-HKP6OAJP.js → chunk-7LT3YZUR.js} +12 -0
  9. package/dist/{chunk-FXWBFDQR.js → chunk-AP2KQMMW.js} +48 -9
  10. package/dist/{chunk-DMK5ASKV.js → chunk-F6D766VQ.js} +6 -2
  11. package/dist/{chunk-VP5PFNZO.js → chunk-HASADR3E.js} +73 -20
  12. package/dist/{chunk-ICL6T4SN.js → chunk-IQ7VNWH6.js} +26 -2
  13. package/dist/{chunk-G7A5LEVI.js → chunk-LN7XJ56R.js} +6 -6
  14. package/dist/{chunk-DN3LKLYR.js → chunk-P2TBUKLW.js} +3 -3
  15. package/dist/{chunk-Q7XQLKZB.js → chunk-PIZDPY4G.js} +20 -3
  16. package/dist/{chunk-JHKBALS2.js → chunk-RMADJFA7.js} +1 -1
  17. package/dist/{chunk-7HN63Q7J.js → chunk-RQRO6PC7.js} +40 -6
  18. package/dist/{chunk-QT4D5XCI.js → chunk-TAWRJ24T.js} +1 -1
  19. package/dist/{chunk-AI47FDGL.js → chunk-UO4MZQNO.js} +1 -1
  20. package/dist/chunk-UTKUO2KY.js +102 -0
  21. package/dist/{chunk-CDKQJWGK.js → chunk-VDZ4I4ZP.js} +160 -18
  22. package/dist/cli.js +16 -16
  23. package/dist/{clock-ZT5YT6HA.js → clock-SH4NQC2F.js} +3 -3
  24. package/dist/{cloud-sync-GPXYPTIC.js → cloud-sync-MJVJ33LD.js} +11 -10
  25. package/dist/{daemon-restart-2EWBRTN5.js → daemon-restart-BIGU4OKS.js} +1 -1
  26. package/dist/daemon.js +543 -236
  27. package/dist/{delete-O7PEFQDF.js → delete-CYVZUAV2.js} +4 -0
  28. package/dist/{delivery-manager-HGVZSEES.js → delivery-manager-FDHCZ7PU.js} +7 -5
  29. package/dist/delivery-notices-65TB3CBX.js +18 -0
  30. package/dist/{echo-text-6ANI5HHN.js → echo-text-KU6G4Z7V.js} +12 -11
  31. package/dist/{extensions-NY6WST33.js → extensions-VACUKFA5.js} +11 -10
  32. package/dist/{files-BRY5RRIM.js → files-CHPNEQXN.js} +5 -4
  33. package/dist/{join-PYA36HEC.js → join-KLRU72JJ.js} +10 -2
  34. package/dist/{message-delivery-JHMAJ2GU.js → message-delivery-TUXM3AAE.js} +15 -12
  35. package/dist/{mind-WF6QIXWV.js → mind-IX5AWUST.js} +13 -13
  36. package/dist/{mind-contacts-QYU4TPEA.js → mind-contacts-BKUOUJPV.js} +5 -5
  37. package/dist/{mind-history-OGNR4L7W.js → mind-history-RVF6KJ7K.js} +83 -11
  38. package/dist/{mind-list-SYRA7CH5.js → mind-list-KNU34SUD.js} +2 -2
  39. package/dist/{mind-service-DZCUUC7J.js → mind-service-WHFBUKZE.js} +11 -10
  40. package/dist/{mind-sleep-LZAFBDZD.js → mind-sleep-D3DEYAWG.js} +5 -5
  41. package/dist/{mind-status-Z2LQFA6F.js → mind-status-BPTLQ5Z6.js} +15 -3
  42. package/dist/{package-B6O5APCZ.js → package-BN5KMU54.js} +2 -2
  43. package/dist/{prompts-NBO3HRK3.js → prompts-HNKGCAEY.js} +1 -1
  44. package/dist/{reject-TWB4QJHG.js → reject-K2A4UK2Y.js} +9 -5
  45. package/dist/{scheduler-TNBN54ZK.js → scheduler-5X5UIQOQ.js} +7 -5
  46. package/dist/{seed-K2LYODIH.js → seed-LHZ33UB4.js} +1 -1
  47. package/dist/{seed-cmd-2EWBTR6P.js → seed-cmd-U4RVJ3UA.js} +2 -2
  48. package/dist/{seed-create-VGTLU5FD.js → seed-create-OQU4KNCB.js} +1 -1
  49. package/dist/{seed-readiness-DYOVCYHN.js → seed-readiness-WJYYRRCK.js} +2 -2
  50. package/dist/{seed-sprout-PILHJ3YS.js → seed-sprout-SFUYH2RG.js} +7 -7
  51. package/dist/{send-2YKOZJJ5.js → send-FWZAMDBN.js} +35 -20
  52. package/dist/{setup-MDPSK2XG.js → setup-RVSGCIF3.js} +1 -1
  53. package/dist/skills/memory/SKILL.md +47 -43
  54. package/dist/skills/volute-mind/references/variants.md +1 -1
  55. package/dist/{skills-NH2GQKAF.js → skills-QRPU6BWY.js} +1 -1
  56. package/dist/{sleep-manager-7JXEQQ2P.js → sleep-manager-NWUCRBZB.js} +11 -10
  57. package/dist/{spirit-25JVAL3M.js → spirit-I5ECLGRI.js} +16 -2
  58. package/dist/{spirit-availability-PJDWZJ3J.js → spirit-availability-NIUUYKU2.js} +12 -11
  59. package/dist/{sprout-EXUZMIOR.js → sprout-A2PMEF7Z.js} +1 -1
  60. package/dist/{system-events-ZPSH63QX.js → system-events-YTOU66QI.js} +7 -1
  61. package/dist/{systems-VDRUZXNF.js → systems-2DIJY4RF.js} +6 -6
  62. package/dist/{turn-tracker-TAT2HGK6.js → turn-tracker-NNHS6MAR.js} +4 -2
  63. package/dist/{up-J7XCAH7P.js → up-UGBHH743.js} +1 -1
  64. package/dist/{variant-cleanup-YXN25ZVW.js → variant-cleanup-CZDLIY3F.js} +7 -5
  65. package/dist/{version-notify-EN3KJ7HH.js → version-notify-SJVAQLZI.js} +4 -4
  66. package/dist/web-assets/assets/index-BHSU9r-4.css +1 -0
  67. package/dist/web-assets/assets/index-b7G7ZsVk.js +97 -0
  68. package/dist/web-assets/index.html +2 -2
  69. package/package.json +2 -2
  70. package/templates/_base/.init/.config/prompts.json +1 -1
  71. package/templates/_base/src/lib/auto-commit.ts +49 -11
  72. package/templates/_base/src/lib/codex-session-seed.ts +463 -0
  73. package/templates/_base/src/lib/daemon-client.ts +35 -0
  74. package/templates/_base/src/lib/pi-session-seed.ts +434 -0
  75. package/templates/_base/src/lib/seed-note.ts +78 -0
  76. package/templates/_base/src/lib/session-seed.ts +375 -0
  77. package/templates/_base/src/lib/startup.ts +108 -4
  78. package/templates/claude/.init/CLAUDE.md +3 -3
  79. package/templates/claude/src/agent.ts +267 -89
  80. package/templates/claude/src/lib/hooks/pre-compact.ts +30 -5
  81. package/templates/claude/src/lib/stream-consumer.ts +13 -0
  82. package/templates/claude/src/server.ts +2 -1
  83. package/templates/codex/.init/AGENTS.md +3 -3
  84. package/templates/codex/src/agent.ts +256 -39
  85. package/templates/codex/src/server.ts +2 -1
  86. package/templates/pi/.init/MINDS.md +3 -3
  87. package/templates/pi/src/agent.ts +269 -43
  88. package/templates/pi/src/lib/event-handler.ts +11 -0
  89. package/templates/pi/src/server.ts +3 -1
  90. package/dist/web-assets/assets/index-BOyJ4Gqs.css +0 -1
  91. package/dist/web-assets/assets/index-CrxBwnzp.js +0 -95
  92. package/dist/{backup-YVGV2ZNM.js → backup-WWXVIMYQ.js} +5 -5
  93. package/dist/{bridge-YK7NTRU5.js → bridge-KU2IKSH3.js} +3 -3
  94. package/dist/{channels-FSAHOQWI.js → channels-LW5ZLMSQ.js} +3 -3
  95. package/dist/{chunk-IWAVIJO2.js → chunk-BDHKCLWK.js} +0 -0
  96. package/dist/{chunk-EEOMA4DX.js → chunk-HTB7KDRU.js} +3 -3
  97. package/dist/{create-M727VUDF.js → create-C3XZACTV.js} +3 -3
  98. package/dist/{env-IBW2QI6K.js → env-BJGM6FBA.js} +5 -5
  99. package/dist/{extension-MRYJWSMV.js → extension-B2W52WZY.js} +3 -3
  100. package/dist/{list-UQGIW7OF.js → list-R2GDWOEE.js} +3 -3
  101. package/dist/{login-RJJTCVR3.js → login-EZ4RRDQX.js} +3 -3
  102. package/dist/{login-SB5OENMV.js → login-JDOAK465.js} +4 -4
  103. package/dist/{logout-YFZAJRPY.js → logout-K6ZGAJY5.js} +3 -3
  104. package/dist/{logout-BYV433X6.js → logout-MWSIUOQU.js} +3 -3
  105. package/dist/{mind-wake-LDGPRA42.js → mind-wake-DCDFFQKX.js} +3 -3
  106. package/dist/{read-RJ5NAW5J.js → read-J5RM674N.js} +3 -3
  107. package/dist/{register-OX4HRSGC.js → register-53LBGSDC.js} +3 -3
  108. package/dist/{restart-AGEGNSYQ.js → restart-R2WFNF5K.js} +4 -4
  109. package/dist/{skill-B5OJEQUA.js → skill-DXLLSUHB.js} +5 -5
  110. package/dist/{start-MUMNFR52.js → start-XDOUAWN3.js} +4 -4
  111. package/dist/{status-MWLE3RVC.js → status-EYG4A3OK.js} +3 -3
  112. package/dist/{stop-EKERNYTX.js → stop-O62RTEMD.js} +4 -4
@@ -24,11 +24,25 @@ import {
24
24
  import { daemonEmit } from "./lib/daemon-client.js";
25
25
  import { dispatchPrompt } from "./lib/dispatch.js";
26
26
  import { createEventHandler, emit } from "./lib/event-handler.js";
27
+ import { compactTimestamp } from "./lib/format-prefix.js";
27
28
  import { runHooks } from "./lib/hook-loader.js";
28
29
  import { log } from "./lib/logger.js";
30
+ import {
31
+ computePiSeedCut,
32
+ DEFAULT_SEED_TOKENS,
33
+ type PiSeedCut,
34
+ rotatePiSession,
35
+ seedPiSession,
36
+ } from "./lib/pi-session-seed.js";
29
37
  import { createReplyInstructionsExtension } from "./lib/reply-instructions-extension.js";
30
38
  import { resolveModel } from "./lib/resolve-model.js";
31
- import { getStartupContext, loadPrompts, type SubagentConfig } from "./lib/startup.js";
39
+ import { buildSeededNote, type SeedCause } from "./lib/seed-note.js";
40
+ import {
41
+ getStartupContext,
42
+ loadPrompts,
43
+ renderCompactionWarning,
44
+ type SubagentConfig,
45
+ } from "./lib/startup.js";
32
46
  import { createSubagentExtension, type SubagentDefinition } from "./lib/subagents.js";
33
47
  import type {
34
48
  HandlerMeta,
@@ -52,16 +66,51 @@ type PiSession = {
52
66
  currentMessageId?: string;
53
67
  messageChannels: Map<string, { channel: string; sender?: string }>;
54
68
  contextTokens: number;
69
+ /**
70
+ * True when this session was seeded from the previous session's transcript.
71
+ * Consumed once, on the first turn, to inject the honest-boundary note.
72
+ */
73
+ seeded?: boolean;
74
+ /** When the seeded-from session was archived (epoch ms), for the gap note; null if unknown. */
75
+ seededArchivedAt?: number | null;
76
+ /** Why the tail is seeded — picks the boundary note's wording. Last cause wins. */
77
+ seededCause?: SeedCause;
78
+ /**
79
+ * Set after an in-place rotation so the mind is told, on its next turn, that the
80
+ * session rotated at the context limit. Delivered separately from the restored-seed
81
+ * note (which fires only on the first turn of a freshly created agent session).
82
+ */
83
+ rotationNotePending?: boolean;
84
+ /**
85
+ * The cut point pinned at warn time (the first-kept turn the mind was told would
86
+ * survive verbatim). Honored at rotation so the promise in the warning holds even
87
+ * though the transcript grows after the warning. Null until a warning fires.
88
+ */
89
+ rotationCut?: PiSeedCut | null;
90
+ /**
91
+ * Back-to-back rotations that did NOT bring context under the threshold (reset by
92
+ * any healthy turn). Guards against a runaway loop when the tail alone can't fit —
93
+ * e.g. a system prompt (large MEMORY.md) that already fills most of the window,
94
+ * which rotation can't trim. Past the cap we defer to the SDK's native compaction.
95
+ */
96
+ consecutiveRotations?: number;
55
97
  };
56
98
 
99
+ /** Stop self-rotating after this many back-to-back rotations that didn't reduce context. */
100
+ const MAX_CONSECUTIVE_ROTATIONS = 3;
101
+
57
102
  export function createMind(options: {
58
103
  systemPrompt: string;
59
104
  cwd: string;
60
105
  mindDir: string;
106
+ /** Directory holding pi session subdirs (`<sessionsDir>/<name>/*.jsonl`). */
107
+ sessionsDir: string;
61
108
  model?: string;
62
109
  thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
63
110
  compactionMessage?: string;
64
111
  maxContextTokens?: number;
112
+ /** Estimated-token budget for seeding a fresh persistent session. 0 disables. Default 30000. */
113
+ seedTokens?: number;
65
114
  subagents?: Record<string, SubagentConfig>;
66
115
  }): {
67
116
  resolve: HandlerResolver;
@@ -70,12 +119,11 @@ export function createMind(options: {
70
119
  } {
71
120
  const sessions = new Map<string, PiSession>();
72
121
  const prompts = loadPrompts();
73
- const today = new Date().toLocaleDateString("en-CA");
74
- const compactionMessage =
75
- // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${date} in prompt template
76
- options.compactionMessage ?? prompts.compaction_warning.replace("${date}", today);
122
+ const compactionMessage = () =>
123
+ options.compactionMessage ?? renderCompactionWarning(prompts.compaction_warning);
77
124
  const compactionInstructions = prompts.compaction_instructions;
78
125
  const maxContextTokens = options.maxContextTokens;
126
+ const seedTokens = options.seedTokens ?? DEFAULT_SEED_TOKENS;
79
127
 
80
128
  if (maxContextTokens) {
81
129
  log("mind", `compaction threshold: ${maxContextTokens} tokens`);
@@ -183,6 +231,22 @@ export function createMind(options: {
183
231
  // Inject startup context on the first turn of each session
184
232
  if (!startupContextInjected) {
185
233
  startupContextInjected = true;
234
+ // On the first turn of a seeded session, lead with the honest-boundary
235
+ // note (consumed once) so the restored transcript above isn't mistaken
236
+ // for a continuously-lived conversation.
237
+ if (session.seeded) {
238
+ session.seeded = false;
239
+ const note = buildSeededNote({
240
+ cause: "restored",
241
+ archivedAtMs: session.seededArchivedAt ?? null,
242
+ });
243
+ emit(session, {
244
+ type: "context",
245
+ content: note,
246
+ metadata: { source: "seeded-session" },
247
+ });
248
+ parts.push(note);
249
+ }
186
250
  const startupContext = await startupContextPromise;
187
251
  if (startupContext) {
188
252
  emit(session, {
@@ -194,6 +258,20 @@ export function createMind(options: {
194
258
  }
195
259
  }
196
260
 
261
+ // On the first turn after an in-place rotation, tell the mind the earlier
262
+ // turns collapsed at the context limit (distinct from the restored-seed note,
263
+ // which only fires on a freshly created agent session). Consumed once.
264
+ if (session.rotationNotePending) {
265
+ session.rotationNotePending = false;
266
+ const note = buildSeededNote({ cause: "rotation" });
267
+ emit(session, {
268
+ type: "context",
269
+ content: note,
270
+ metadata: { source: "seeded-session" },
271
+ });
272
+ parts.push(note);
273
+ }
274
+
197
275
  try {
198
276
  const result = await runHooks(hooksDir, "pre-prompt", {
199
277
  event: "pre-prompt",
@@ -273,16 +351,49 @@ export function createMind(options: {
273
351
  async function initSession(session: PiSession) {
274
352
  const isEphemeral = session.name.startsWith("new-");
275
353
 
276
- const sessionManager = isEphemeral
277
- ? SessionManager.inMemory()
278
- : SessionManager.continueRecent(options.cwd, `.mind/pi-sessions/${session.name}`);
354
+ // Fresh persistent session — seed it from the previous session's archived
355
+ // transcript so the mind experiences the conversation continuing rather than
356
+ // waking into an empty context. seedPiSession no-ops when a live session
357
+ // already exists (continueRecent will resume that instead). Ephemeral
358
+ // `new-*` sessions are one-offs — they never seed at start.
359
+ const seeded = isEphemeral
360
+ ? null
361
+ : seedPiSession({
362
+ cwd: options.cwd,
363
+ piSessionsDir: options.sessionsDir,
364
+ name: session.name,
365
+ seedTokens,
366
+ });
367
+
368
+ // Every session (ephemeral too) is file-backed under its own name-scoped dir,
369
+ // so rotation applies uniformly at the context limit. Ephemerality is only that
370
+ // `new-*` sessions never seed at start and never archive on rotation — not a
371
+ // different backing store. Their unique `new-<ts>-<rand>` names mean the dir is
372
+ // always fresh, so continueRecent starts them empty (no seed to adopt).
373
+ const sessionManager = SessionManager.continueRecent(
374
+ options.cwd,
375
+ resolvePath(options.sessionsDir, session.name),
376
+ );
377
+
378
+ // If continueRecent adopted our seed file, its header id is the seeded id;
379
+ // if it fell back to a truly fresh session it minted a different id, so the
380
+ // honest-boundary note never lies about a conversation that didn't continue.
381
+ if (seeded && sessionManager.getSessionId() === seeded.sessionId) {
382
+ session.seeded = true;
383
+ session.seededArchivedAt = seeded.archivedAt;
384
+ session.seededCause = "restored";
385
+ log("mind", `session "${session.name}": seeded from previous transcript`);
386
+ }
279
387
 
280
388
  log("mind", `session "${session.name}": ${isEphemeral ? "ephemeral" : "persistent"}`);
281
389
 
282
- // Compaction state machine:
283
- // 1. onContextTokens sets compactionTriggered=true and sends warning
390
+ // Compaction state machine (rotation, not SDK /compact):
391
+ // 1. threshold (onContextTokens) or native PreCompact -> warnAndPinRotation:
392
+ // pin the cut from the live transcript, interpolate it into the ${cutoff}
393
+ // warning, send it as the wrap-up prompt, set compactionTriggered
284
394
  // 2. onTurnEnd (after warning turn): compactionTriggered -> compactOnNextTurnEnd
285
- // 3. onTurnEnd (after mind's save turn): compactOnNextTurnEnd -> call compact()
395
+ // 3. onTurnEnd (after mind's wrap-up turn): compactOnNextTurnEnd -> rotateInPlace
396
+ // (or, past the runaway cap, the native compact() backstop)
286
397
  let compactBlocked = false;
287
398
  let manualCompactPending = false;
288
399
  let compactionTriggered = false;
@@ -295,31 +406,144 @@ export function createMind(options: {
295
406
  compactionInProgress = false;
296
407
  }
297
408
 
409
+ /**
410
+ * Warn the mind that the session will rotate at the context limit and pin the cut
411
+ * point it's told about. Computes which whole turns fit in `seedTokens` from the
412
+ * live transcript, pins the first-kept turn (so rotation honors the same boundary
413
+ * even as the transcript grows), interpolates its local time into the `${cutoff}`
414
+ * placeholder, and sends the warning as the wrap-up prompt. Shared by the
415
+ * threshold and native-PreCompact paths.
416
+ */
417
+ function warnAndPinRotation() {
418
+ let cutoffLabel = "the cutoff";
419
+ session.rotationCut = null;
420
+ const sourcePath = session.agentSession?.sessionManager.getSessionFile();
421
+ if (sourcePath) {
422
+ try {
423
+ const cut = computePiSeedCut(readFileSync(sourcePath, "utf-8"), seedTokens);
424
+ if (cut) {
425
+ session.rotationCut = cut;
426
+ if (cut.boundaryTimestamp) {
427
+ const d = new Date(cut.boundaryTimestamp);
428
+ if (!Number.isNaN(d.getTime())) cutoffLabel = compactTimestamp(d);
429
+ }
430
+ }
431
+ } catch (err) {
432
+ log("mind", `session "${session.name}": failed to pin rotation cut:`, err);
433
+ }
434
+ }
435
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal ${cutoff} prompt placeholder
436
+ const warning = compactionMessage().replaceAll("${cutoff}", cutoffLabel);
437
+ compactionTriggered = true;
438
+ session.messageIds.push(undefined);
439
+ session.agentSession?.prompt(warning, { streamingBehavior: "followUp" });
440
+ }
441
+
442
+ /**
443
+ * Rotate the session in place onto a synthetic session holding the verbatim recent
444
+ * tail (from the pinned cut), then switch the running SessionManager to it. Returns
445
+ * false if rotation can't proceed (session not ready, or the file build failed) so
446
+ * the caller can fall back to a fresh session. The setSessionFile + state.messages
447
+ * re-sync is the load-bearing adoption step: the agent caches context in
448
+ * state.messages, so swapping the file alone wouldn't change what the mind sees
449
+ * (this mirrors the SDK's own compact() re-sync).
450
+ */
451
+ function rotateInPlace(): boolean {
452
+ const as = session.agentSession;
453
+ const sourcePath = as?.sessionManager.getSessionFile();
454
+ if (!as || !sourcePath) return false; // not ready — fall back to fresh
455
+ const newPath = rotatePiSession({
456
+ cwd: options.cwd,
457
+ sessionsDir: options.sessionsDir,
458
+ name: session.name,
459
+ sourcePath,
460
+ cut: session.rotationCut ?? null,
461
+ seedTokens,
462
+ });
463
+ session.rotationCut = null;
464
+ if (!newPath) return false;
465
+ as.sessionManager.setSessionFile(newPath);
466
+ as.state.messages = as.sessionManager.buildSessionContext().messages;
467
+ session.rotationNotePending = true;
468
+ session.seededCause = "rotation";
469
+ session.consecutiveRotations = (session.consecutiveRotations ?? 0) + 1;
470
+ compactBlocked = false; // re-arm the native-compaction backstop
471
+ log(
472
+ "mind",
473
+ `session "${session.name}": rotated in place → ${as.sessionManager.getSessionId()} (rotation ${session.consecutiveRotations})`,
474
+ );
475
+ return true;
476
+ }
477
+
478
+ /**
479
+ * Rotation couldn't proceed — start a fresh session in place (new empty transcript,
480
+ * cleared context), matching the claude template's rotation-failure fallback. Fresh
481
+ * is a far smaller cliff now that seeding exists; crucially it is NOT a silent native
482
+ * compaction.
483
+ */
484
+ function freshFallback() {
485
+ const as = session.agentSession;
486
+ if (as) {
487
+ as.sessionManager.newSession();
488
+ as.state.messages = [];
489
+ }
490
+ session.rotationCut = null;
491
+ session.consecutiveRotations = 0;
492
+ compactBlocked = false;
493
+ log("mind", `session "${session.name}": rotation failed, starting fresh`);
494
+ }
495
+
496
+ /**
497
+ * The SDK's native compaction, used as the emergency backstop past the runaway cap —
498
+ * when rotation can't relieve context (e.g. the system prompt alone fills most of the
499
+ * window, so no tail fits under the threshold). The sole remaining use of the custom
500
+ * compaction instructions.
501
+ */
502
+ function runBackstopCompact() {
503
+ manualCompactPending = true;
504
+ compactionInProgress = true;
505
+ log("mind", `session "${session.name}": rotation cap reached — native compaction backstop`);
506
+ Promise.resolve(session.agentSession?.compact(compactionInstructions))
507
+ .catch((err) => log("mind", `session "${session.name}": backstop compact() failed:`, err))
508
+ .finally(() => {
509
+ compactionInProgress = false;
510
+ });
511
+ }
512
+
298
513
  const preCompactExtension: ExtensionFactory = (pi) => {
299
514
  pi.on("session_before_compact", () => {
300
- // Our programmatic compact() call (triggered by token threshold) — allow through
515
+ // Our own backstop compact() call (past the runaway cap) — allow through.
301
516
  if (manualCompactPending) {
302
517
  manualCompactPending = false;
303
- log(
304
- "mind",
305
- `session "${session.name}": allowing manual compaction with custom instructions`,
306
- );
518
+ log("mind", `session "${session.name}": allowing native compaction backstop`);
307
519
  return;
308
520
  }
309
521
 
310
- // Auto-compaction: two-pass block (first pass warns mind, second pass allows)
522
+ // The SDK's native auto-compaction wants to fire. Converge it onto rotation:
523
+ // the first pass warns + pins + marks for rotation (unless the threshold path
524
+ // already did, or the runaway cap is hit) and blocks the native compaction;
525
+ // the second pass allows native compaction as the emergency backstop (only
526
+ // reachable if rotation never brought context under control).
311
527
  if (!compactBlocked) {
312
528
  compactBlocked = true;
313
- log(
314
- "mind",
315
- `session "${session.name}": blocking compaction — asking mind to update daily log`,
316
- );
317
- session.messageIds.push(undefined);
318
- session.agentSession?.prompt(compactionMessage, { streamingBehavior: "followUp" });
529
+ if (
530
+ !compactionTriggered &&
531
+ !compactOnNextTurnEnd &&
532
+ !compactionInProgress &&
533
+ (session.consecutiveRotations ?? 0) < MAX_CONSECUTIVE_ROTATIONS
534
+ ) {
535
+ log(
536
+ "mind",
537
+ `session "${session.name}": native compaction — warning + pinning rotation`,
538
+ );
539
+ warnAndPinRotation();
540
+ } else {
541
+ log("mind", `session "${session.name}": blocking native compaction (rotation pending)`);
542
+ }
319
543
  return { cancel: true };
320
544
  }
321
545
  compactBlocked = false;
322
- log("mind", `session "${session.name}": allowing compaction`);
546
+ log("mind", `session "${session.name}": allowing native compaction backstop`);
323
547
  });
324
548
  };
325
549
 
@@ -372,7 +596,9 @@ export function createMind(options: {
372
596
  maxContextTokens &&
373
597
  tokens >= maxContextTokens &&
374
598
  !compactionTriggered &&
375
- !compactionInProgress
599
+ !compactOnNextTurnEnd &&
600
+ !compactionInProgress &&
601
+ (session.consecutiveRotations ?? 0) < MAX_CONSECUTIVE_ROTATIONS
376
602
  ) {
377
603
  if (!session.agentSession) {
378
604
  log(
@@ -381,39 +607,39 @@ export function createMind(options: {
381
607
  );
382
608
  return;
383
609
  }
384
- compactionTriggered = true;
385
610
  log(
386
611
  "mind",
387
- `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — triggering compaction`,
612
+ `session "${session.name}": ${tokens} tokens >= ${maxContextTokens} — warning + pinning rotation`,
388
613
  );
389
- // Send compaction warning; compaction will follow after the mind finishes its response turn
390
- session.messageIds.push(undefined);
391
- session.agentSession.prompt(compactionMessage, {
392
- streamingBehavior: "followUp",
393
- });
614
+ warnAndPinRotation();
394
615
  }
395
616
  },
396
617
  onTurnEnd: maxContextTokens
397
618
  ? () => {
398
619
  try {
399
- // Compact on the turn AFTER the warning was sent (so the mind gets a turn to save state)
620
+ // The wrap-up turn (after the warning) is done rotate the session in
621
+ // place onto the verbatim tail, honoring the pinned cut.
400
622
  if (compactOnNextTurnEnd) {
401
623
  compactOnNextTurnEnd = false;
402
- manualCompactPending = true;
403
- compactionInProgress = true;
404
- log("mind", `session "${session.name}": compacting with custom instructions`);
405
- Promise.resolve(session.agentSession?.compact(compactionInstructions))
406
- .catch((err) =>
407
- log("mind", `session "${session.name}": compact() failed:`, err),
408
- )
409
- .finally(() => {
410
- compactionInProgress = false;
411
- });
624
+ if ((session.consecutiveRotations ?? 0) >= MAX_CONSECUTIVE_ROTATIONS) {
625
+ // Runaway guard: rotation isn't relieving context (system prompt too
626
+ // large to fit the tail under the threshold) — defer to the SDK.
627
+ runBackstopCompact();
628
+ } else if (!rotateInPlace()) {
629
+ // Rotation couldn't proceed — fresh session, not a silent native
630
+ // compaction.
631
+ freshFallback();
632
+ }
633
+ return;
412
634
  }
413
635
  if (compactionTriggered) {
414
636
  compactionTriggered = false;
415
637
  compactOnNextTurnEnd = true;
638
+ return;
416
639
  }
640
+ // A healthy turn (context under the threshold) — the rotation streak,
641
+ // if any, is over, so re-arm the self-rotation cap.
642
+ session.consecutiveRotations = 0;
417
643
  } catch (err) {
418
644
  log(
419
645
  "mind",
@@ -144,6 +144,17 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
144
144
  if (messageId) {
145
145
  session.messageChannels.delete(messageId);
146
146
  }
147
+ // pi's agent loop drains every queued prompt (follow-ups, steers) before
148
+ // a final agent_end, so anything still pending here was consumed by this
149
+ // turn. Prune it all — a stranded id would be shifted in as the NEXT
150
+ // turn's id, tagging that turn's rows with the wrong channel (#700).
151
+ // Exception: on a retryable error the loop emits agent_end WITHOUT
152
+ // draining and the session retries — those queued prompts haven't run
153
+ // yet, so they keep their entries for the continuation.
154
+ if (!event.willRetry) {
155
+ session.messageIds.length = 0;
156
+ session.messageChannels.clear();
157
+ }
147
158
  const channel = messageId ? session.messageChannels.get(messageId)?.channel : undefined;
148
159
  log("mind", `session "${session.name}": turn done`);
149
160
  // Collect any agent-level errors (e.g. a provider 401 reported inside agent_end).
@@ -18,7 +18,7 @@ if (config.logLevel) setLevel(config.logLevel);
18
18
  if (config.model) log("server", `using model: ${config.model}`);
19
19
  if (config.thinkingLevel) log("server", `thinking level: ${config.thinkingLevel}`);
20
20
 
21
- const systemPrompt = loadSystemPrompt();
21
+ const systemPrompt = loadSystemPrompt(config);
22
22
  const pkg = loadPackageInfo();
23
23
 
24
24
  const mindDir = resolve(".");
@@ -26,10 +26,12 @@ const mind = createMind({
26
26
  systemPrompt,
27
27
  cwd: resolve("home"),
28
28
  mindDir,
29
+ sessionsDir: resolve(".mind/pi-sessions"),
29
30
  model: config.model,
30
31
  thinkingLevel: config.thinkingLevel,
31
32
  compactionMessage: config.compactionMessage,
32
33
  maxContextTokens: config.compaction?.maxContextTokens,
34
+ seedTokens: config.continuity?.seedTokens,
33
35
  subagents: config.subagents,
34
36
  });
35
37