volute 0.52.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.
- package/dist/{accept-5NK5XJB5.js → accept-I4GVGWA3.js} +6 -3
- package/dist/{chat-VAZ25KVT.js → chat-WK2BLM7B.js} +9 -9
- package/dist/{chunk-724DVYHU.js → chunk-23UJGVYP.js} +9 -9
- package/dist/{chunk-K2F237KS.js → chunk-5DBGQBQR.js} +138 -13
- package/dist/chunk-5RBEEAKW.js +58 -0
- package/dist/{chunk-XRWUQPCA.js → chunk-7ARONCZW.js} +24 -2
- package/dist/{chunk-FXWBFDQR.js → chunk-AP2KQMMW.js} +48 -9
- package/dist/{chunk-DMK5ASKV.js → chunk-F6D766VQ.js} +6 -2
- package/dist/{chunk-ZEZQNP2G.js → chunk-HASADR3E.js} +46 -9
- package/dist/{chunk-AXFLY5GU.js → chunk-IQ7VNWH6.js} +16 -1
- package/dist/{chunk-G7A5LEVI.js → chunk-LN7XJ56R.js} +6 -6
- package/dist/{chunk-CW7BQKF6.js → chunk-P2TBUKLW.js} +3 -3
- package/dist/{chunk-SW2U6HRB.js → chunk-PIZDPY4G.js} +20 -3
- package/dist/{chunk-JHKBALS2.js → chunk-RMADJFA7.js} +1 -1
- package/dist/{chunk-RU74YG46.js → chunk-RQRO6PC7.js} +40 -6
- package/dist/{chunk-RPPTGPYF.js → chunk-TAWRJ24T.js} +1 -1
- package/dist/{chunk-ITK6HV47.js → chunk-UO4MZQNO.js} +1 -1
- package/dist/chunk-UTKUO2KY.js +102 -0
- package/dist/{chunk-YVR6EHWL.js → chunk-VDZ4I4ZP.js} +160 -18
- package/dist/cli.js +16 -16
- package/dist/{clock-ZT5YT6HA.js → clock-SH4NQC2F.js} +3 -3
- package/dist/{cloud-sync-H7EWZASD.js → cloud-sync-MJVJ33LD.js} +10 -9
- package/dist/{daemon-restart-4FWUA5JF.js → daemon-restart-BIGU4OKS.js} +1 -1
- package/dist/daemon.js +261 -89
- package/dist/{delivery-manager-VYXVKUMP.js → delivery-manager-FDHCZ7PU.js} +7 -5
- package/dist/delivery-notices-65TB3CBX.js +18 -0
- package/dist/{echo-text-PEEV7FQU.js → echo-text-KU6G4Z7V.js} +11 -10
- package/dist/{extensions-N6L5NYTK.js → extensions-VACUKFA5.js} +10 -9
- package/dist/{files-BRY5RRIM.js → files-CHPNEQXN.js} +5 -4
- package/dist/{message-delivery-7URC52ME.js → message-delivery-TUXM3AAE.js} +14 -11
- package/dist/{mind-NEXGT3KE.js → mind-IX5AWUST.js} +11 -11
- package/dist/{mind-contacts-QYU4TPEA.js → mind-contacts-BKUOUJPV.js} +5 -5
- package/dist/{mind-history-OGNR4L7W.js → mind-history-RVF6KJ7K.js} +83 -11
- package/dist/{mind-list-SYRA7CH5.js → mind-list-KNU34SUD.js} +2 -2
- package/dist/{mind-service-E26ZQXDS.js → mind-service-WHFBUKZE.js} +10 -9
- package/dist/{mind-sleep-LZAFBDZD.js → mind-sleep-D3DEYAWG.js} +5 -5
- package/dist/{mind-status-Z2LQFA6F.js → mind-status-BPTLQ5Z6.js} +15 -3
- package/dist/{package-2W2XKSIP.js → package-BN5KMU54.js} +1 -1
- package/dist/{prompts-NBO3HRK3.js → prompts-HNKGCAEY.js} +1 -1
- package/dist/{reject-TWB4QJHG.js → reject-K2A4UK2Y.js} +9 -5
- package/dist/{scheduler-JTTWZDYU.js → scheduler-5X5UIQOQ.js} +7 -5
- package/dist/{seed-64XEHRUP.js → seed-LHZ33UB4.js} +1 -1
- package/dist/{seed-cmd-UEL2QB3Q.js → seed-cmd-U4RVJ3UA.js} +2 -2
- package/dist/{seed-create-4JRQDPGA.js → seed-create-OQU4KNCB.js} +1 -1
- package/dist/{seed-readiness-DYOVCYHN.js → seed-readiness-WJYYRRCK.js} +2 -2
- package/dist/{seed-sprout-NIDIOJXT.js → seed-sprout-SFUYH2RG.js} +7 -7
- package/dist/{send-2YKOZJJ5.js → send-FWZAMDBN.js} +35 -20
- package/dist/{setup-4IX6PCD3.js → setup-RVSGCIF3.js} +1 -1
- package/dist/skills/memory/SKILL.md +47 -43
- package/dist/{skills-MPUALV3S.js → skills-QRPU6BWY.js} +1 -1
- package/dist/{sleep-manager-OFP2GFXF.js → sleep-manager-NWUCRBZB.js} +10 -9
- package/dist/{spirit-4ECYIVGE.js → spirit-I5ECLGRI.js} +16 -2
- package/dist/{spirit-availability-Y54Y3675.js → spirit-availability-NIUUYKU2.js} +11 -10
- package/dist/{sprout-GJMZFRGZ.js → sprout-A2PMEF7Z.js} +1 -1
- package/dist/{system-events-V43HDA3T.js → system-events-YTOU66QI.js} +5 -1
- package/dist/{systems-VDRUZXNF.js → systems-2DIJY4RF.js} +6 -6
- package/dist/{turn-tracker-C5PVIC3B.js → turn-tracker-NNHS6MAR.js} +4 -2
- package/dist/{up-H6LJZDRK.js → up-UGBHH743.js} +1 -1
- package/dist/{variant-cleanup-STC6B5HR.js → variant-cleanup-CZDLIY3F.js} +7 -5
- package/dist/{version-notify-LBVHH5YV.js → version-notify-SJVAQLZI.js} +4 -4
- package/dist/web-assets/assets/index-BHSU9r-4.css +1 -0
- package/dist/web-assets/assets/{index-B_eYTa3R.js → index-b7G7ZsVk.js} +13 -13
- package/dist/web-assets/index.html +2 -2
- package/package.json +1 -1
- package/templates/_base/.init/.config/prompts.json +1 -1
- package/templates/_base/src/lib/codex-session-seed.ts +463 -0
- package/templates/_base/src/lib/daemon-client.ts +35 -0
- package/templates/_base/src/lib/pi-session-seed.ts +434 -0
- package/templates/_base/src/lib/seed-note.ts +78 -0
- package/templates/_base/src/lib/session-seed.ts +375 -0
- package/templates/_base/src/lib/startup.ts +108 -4
- package/templates/claude/.init/CLAUDE.md +3 -3
- package/templates/claude/src/agent.ts +267 -89
- package/templates/claude/src/lib/hooks/pre-compact.ts +30 -5
- package/templates/claude/src/lib/stream-consumer.ts +13 -0
- package/templates/claude/src/server.ts +2 -1
- package/templates/codex/.init/AGENTS.md +3 -3
- package/templates/codex/src/agent.ts +256 -39
- package/templates/codex/src/server.ts +2 -1
- package/templates/pi/.init/MINDS.md +3 -3
- package/templates/pi/src/agent.ts +269 -43
- package/templates/pi/src/lib/event-handler.ts +11 -0
- package/templates/pi/src/server.ts +3 -1
- package/dist/web-assets/assets/index-DhZrzddW.css +0 -1
- package/dist/{backup-YVGV2ZNM.js → backup-WWXVIMYQ.js} +5 -5
- package/dist/{bridge-YK7NTRU5.js → bridge-KU2IKSH3.js} +3 -3
- package/dist/{channels-FSAHOQWI.js → channels-LW5ZLMSQ.js} +3 -3
- package/dist/{chunk-VPOMJMV6.js → chunk-BDHKCLWK.js} +0 -0
- package/dist/{chunk-EEOMA4DX.js → chunk-HTB7KDRU.js} +3 -3
- package/dist/{create-M727VUDF.js → create-C3XZACTV.js} +3 -3
- package/dist/{env-IBW2QI6K.js → env-BJGM6FBA.js} +5 -5
- package/dist/{extension-MRYJWSMV.js → extension-B2W52WZY.js} +3 -3
- package/dist/{list-UQGIW7OF.js → list-R2GDWOEE.js} +3 -3
- package/dist/{login-RJJTCVR3.js → login-EZ4RRDQX.js} +3 -3
- package/dist/{login-SB5OENMV.js → login-JDOAK465.js} +4 -4
- package/dist/{logout-YFZAJRPY.js → logout-K6ZGAJY5.js} +3 -3
- package/dist/{logout-BYV433X6.js → logout-MWSIUOQU.js} +3 -3
- package/dist/{mind-wake-LDGPRA42.js → mind-wake-DCDFFQKX.js} +3 -3
- package/dist/{read-RJ5NAW5J.js → read-J5RM674N.js} +3 -3
- package/dist/{register-OX4HRSGC.js → register-53LBGSDC.js} +3 -3
- package/dist/{restart-AGEGNSYQ.js → restart-R2WFNF5K.js} +4 -4
- package/dist/{skill-B5OJEQUA.js → skill-DXLLSUHB.js} +5 -5
- package/dist/{start-MUMNFR52.js → start-XDOUAWN3.js} +4 -4
- package/dist/{status-MWLE3RVC.js → status-EYG4A3OK.js} +3 -3
- package/dist/{stop-EKERNYTX.js → stop-O62RTEMD.js} +4 -4
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session seeding for the pi template.
|
|
3
|
+
*
|
|
4
|
+
* When a pi mind starts a fresh *persistent* session (its live
|
|
5
|
+
* `.mind/pi-sessions/<name>/` directory was archived away on sleep, or never
|
|
6
|
+
* existed), we seed the new session by copying the tail of the previous
|
|
7
|
+
* session's transcript into a new session file. `SessionManager.continueRecent`
|
|
8
|
+
* then resumes it natively, so the mind experiences the same conversation
|
|
9
|
+
* continuing rather than waking into an empty context.
|
|
10
|
+
*
|
|
11
|
+
* Pi's session format differs from the Claude Agent SDK's:
|
|
12
|
+
*
|
|
13
|
+
* - A file is a header line `{type:"session", version, id, timestamp, cwd, …}`
|
|
14
|
+
* followed by JSONL entries. Each entry has its own `id`/`parentId` forming a
|
|
15
|
+
* tree; the session id lives only in the header, not on every line.
|
|
16
|
+
* - Message entries are `{type:"message", id, parentId, timestamp, message}`
|
|
17
|
+
* where `message.role` is one of `user` / `assistant` / `toolResult` / `custom`.
|
|
18
|
+
* Tool results are their own role, so an incoming prompt is exactly a
|
|
19
|
+
* `user`-role message — that is the turn boundary.
|
|
20
|
+
* - `continueRecent(cwd, dir)` picks the most-recent `.jsonl` in `dir` **whose
|
|
21
|
+
* header cwd matches** `cwd`, so the seed header's cwd must be rewritten to the
|
|
22
|
+
* mind's home dir (as `importPiSession` already does for imports).
|
|
23
|
+
*
|
|
24
|
+
* The copy is verbatim: message/tool_use/tool_result/thinking entries survive
|
|
25
|
+
* as-is, keeping their own ids. Only two things are rewritten — the header
|
|
26
|
+
* (fresh id, correct cwd, new timestamp, source recorded as `parentSession`) and
|
|
27
|
+
* the first kept entry's `parentId` (nulled, to make the tail a clean root).
|
|
28
|
+
*
|
|
29
|
+
* Nothing here throws: any failure returns null so session start is never blocked.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { randomUUID } from "node:crypto";
|
|
33
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
34
|
+
import { basename, resolve } from "node:path";
|
|
35
|
+
import { findPiSessionFile } from "./context-breakdown.js";
|
|
36
|
+
import { log } from "./logger.js";
|
|
37
|
+
import { parseArchiveTimestamp } from "./seed-note.js";
|
|
38
|
+
import { DEFAULT_SEED_TOKENS } from "./session-seed.js";
|
|
39
|
+
|
|
40
|
+
export { DEFAULT_SEED_TOKENS };
|
|
41
|
+
|
|
42
|
+
// Archived pi-session directories are named `<name>-<timestamp>`, where the
|
|
43
|
+
// timestamp is `new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16)` →
|
|
44
|
+
// `YYYY-MM-DDTHH-MM` (see archiveSessions in the daemon's sleep-manager).
|
|
45
|
+
// Matching the strict shape after the `<name>-` prefix disambiguates a `main`
|
|
46
|
+
// session from a `main-thread` one.
|
|
47
|
+
const ARCHIVE_DIR_SUFFIX = /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2})$/;
|
|
48
|
+
|
|
49
|
+
type PiHeader = {
|
|
50
|
+
type: "session";
|
|
51
|
+
version?: number;
|
|
52
|
+
id: string;
|
|
53
|
+
timestamp: string;
|
|
54
|
+
cwd: string;
|
|
55
|
+
parentSession?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type PiEntry = Record<string, unknown> & {
|
|
59
|
+
type?: string;
|
|
60
|
+
id?: string;
|
|
61
|
+
parentId?: string | null;
|
|
62
|
+
timestamp?: string;
|
|
63
|
+
message?: { role?: string };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** A resolved archive directory: its absolute path and when it was archived. */
|
|
67
|
+
export type ArchivedPiSession = { dir: string; archivedAt: number | null };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Newest archived pi-session directory for `<name>` under `<piSessionsDir>/archive/`,
|
|
71
|
+
* or null if there's no matching directory. `dir` is the absolute path;
|
|
72
|
+
* `archivedAt` is the archive timestamp in epoch millis (null if unparseable).
|
|
73
|
+
*/
|
|
74
|
+
export function findLatestArchivedPiSession(
|
|
75
|
+
piSessionsDir: string,
|
|
76
|
+
name: string,
|
|
77
|
+
): ArchivedPiSession | null {
|
|
78
|
+
const archiveDir = resolve(piSessionsDir, "archive");
|
|
79
|
+
let entries: import("node:fs").Dirent[];
|
|
80
|
+
try {
|
|
81
|
+
entries = readdirSync(archiveDir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const prefix = `${name}-`;
|
|
87
|
+
let bestTs = "";
|
|
88
|
+
let bestDir: string | null = null;
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
|
91
|
+
const match = entry.name.slice(prefix.length).match(ARCHIVE_DIR_SUFFIX);
|
|
92
|
+
if (!match) continue;
|
|
93
|
+
// Timestamps are zero-padded ISO, so lexicographic comparison is chronological.
|
|
94
|
+
if (match[1] > bestTs) {
|
|
95
|
+
bestTs = match[1];
|
|
96
|
+
bestDir = entry.name;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!bestDir) return null;
|
|
100
|
+
return { dir: resolve(archiveDir, bestDir), archivedAt: parseArchiveTimestamp(bestTs) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A turn boundary is a genuine incoming prompt: a `user`-role message entry. */
|
|
104
|
+
function isTurnBoundary(entry: PiEntry): boolean {
|
|
105
|
+
return entry.type === "message" && entry.message?.role === "user";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Estimate tokens for a raw JSONL line as its JSON text length / 4. */
|
|
109
|
+
function estimateTokens(raw: string): number {
|
|
110
|
+
return raw.length / 4;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse pi jsonl into aligned parsed/raw arrays (header at index 0). In strict mode
|
|
115
|
+
* a corrupt line aborts (returns null — used when copying verbatim, where a broken
|
|
116
|
+
* line means we can't faithfully reconstruct the tail). In lenient mode corrupt
|
|
117
|
+
* lines are skipped (used at warn time, when the live transcript may be mid-write).
|
|
118
|
+
*/
|
|
119
|
+
function parsePiJsonl(
|
|
120
|
+
jsonl: string,
|
|
121
|
+
lenient: boolean,
|
|
122
|
+
): { parsed: PiEntry[]; raws: string[] } | null {
|
|
123
|
+
const rawLines = jsonl.split("\n").filter((l) => l.trim().length > 0);
|
|
124
|
+
const parsed: PiEntry[] = [];
|
|
125
|
+
const raws: string[] = [];
|
|
126
|
+
for (const raw of rawLines) {
|
|
127
|
+
let obj: PiEntry;
|
|
128
|
+
try {
|
|
129
|
+
obj = JSON.parse(raw);
|
|
130
|
+
} catch {
|
|
131
|
+
if (lenient) continue;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
parsed.push(obj);
|
|
135
|
+
raws.push(raw);
|
|
136
|
+
}
|
|
137
|
+
return { parsed, raws };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Split a parsed transcript into its header and the entries after it (with aligned
|
|
142
|
+
* raw lines). Returns null if the first line isn't a valid session header — pi
|
|
143
|
+
* rejects files whose first line isn't `{type:"session", id, …}`.
|
|
144
|
+
*/
|
|
145
|
+
function splitPiHeader(
|
|
146
|
+
parsed: PiEntry[],
|
|
147
|
+
raws: string[],
|
|
148
|
+
): { header: PiHeader; entries: PiEntry[]; entryRaws: string[] } | null {
|
|
149
|
+
const header = parsed[0] as PiHeader | undefined;
|
|
150
|
+
if (header?.type !== "session" || typeof header.id !== "string") return null;
|
|
151
|
+
return { header, entries: parsed.slice(1), entryRaws: raws.slice(1) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Indices (into entries) of the genuine turn boundaries (user-role messages). */
|
|
155
|
+
function turnBoundaries(entries: PiEntry[]): number[] {
|
|
156
|
+
const boundaries: number[] = [];
|
|
157
|
+
for (let i = 0; i < entries.length; i++) {
|
|
158
|
+
if (isTurnBoundary(entries[i])) boundaries.push(i);
|
|
159
|
+
}
|
|
160
|
+
return boundaries;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Choose the first-kept entry index: walk backward from the final turn, taking as
|
|
165
|
+
* many whole turns as fit in `seedTokens` (always at least the final turn even if
|
|
166
|
+
* it alone exceeds the budget). Returns a boundary index, or -1 if there's no turn.
|
|
167
|
+
*/
|
|
168
|
+
function tailStartByBudget(entries: PiEntry[], entryRaws: string[], seedTokens: number): number {
|
|
169
|
+
const boundaries = turnBoundaries(entries);
|
|
170
|
+
if (boundaries.length === 0) return -1;
|
|
171
|
+
// Turn t spans entries [boundaries[t], boundaries[t+1]); the last runs to EOF.
|
|
172
|
+
const turnTokens = (t: number): number => {
|
|
173
|
+
const start = boundaries[t];
|
|
174
|
+
const end = t + 1 < boundaries.length ? boundaries[t + 1] : entries.length;
|
|
175
|
+
let sum = 0;
|
|
176
|
+
for (let i = start; i < end; i++) sum += estimateTokens(entryRaws[i]);
|
|
177
|
+
return sum;
|
|
178
|
+
};
|
|
179
|
+
const last = boundaries.length - 1;
|
|
180
|
+
let startTurn = last;
|
|
181
|
+
let accum = turnTokens(last);
|
|
182
|
+
for (let t = last - 1; t >= 0; t--) {
|
|
183
|
+
const cost = turnTokens(t);
|
|
184
|
+
if (accum + cost > seedTokens) break;
|
|
185
|
+
accum += cost;
|
|
186
|
+
startTurn = t;
|
|
187
|
+
}
|
|
188
|
+
return boundaries[startTurn];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type SeededPiTranscript = { sessionId: string; lines: string[] };
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Emit the seeded transcript: a fresh header (new id, rewritten cwd, source recorded
|
|
195
|
+
* as `parentSession`) followed by the tail from `startIdx` through EOF. The tail is
|
|
196
|
+
* copied byte-for-byte from the original raw lines; only the first kept entry is
|
|
197
|
+
* re-serialized, to null its parentId (detaching the tail into a clean root).
|
|
198
|
+
*/
|
|
199
|
+
function emitPiTail(
|
|
200
|
+
header: PiHeader,
|
|
201
|
+
entries: PiEntry[],
|
|
202
|
+
entryRaws: string[],
|
|
203
|
+
startIdx: number,
|
|
204
|
+
cwd: string,
|
|
205
|
+
sourcePath?: string,
|
|
206
|
+
): SeededPiTranscript {
|
|
207
|
+
const newId = randomUUID();
|
|
208
|
+
const newHeader: PiHeader = {
|
|
209
|
+
type: "session",
|
|
210
|
+
version: typeof header.version === "number" ? header.version : 3,
|
|
211
|
+
id: newId,
|
|
212
|
+
timestamp: new Date().toISOString(),
|
|
213
|
+
cwd: resolve(cwd),
|
|
214
|
+
...(sourcePath ? { parentSession: sourcePath } : {}),
|
|
215
|
+
};
|
|
216
|
+
const lines: string[] = [JSON.stringify(newHeader)];
|
|
217
|
+
for (let i = startIdx; i < entries.length; i++) {
|
|
218
|
+
lines.push(i === startIdx ? JSON.stringify({ ...entries[i], parentId: null }) : entryRaws[i]);
|
|
219
|
+
}
|
|
220
|
+
return { sessionId: newId, lines };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build the seeded transcript from a source pi session file's raw jsonl text: take
|
|
225
|
+
* as many whole trailing turns as fit in `seedTokens` (always at least the final
|
|
226
|
+
* turn), keeping the tail entries verbatim. Returns null if there's nothing
|
|
227
|
+
* seedable (no header, no genuine turn, or a corrupt line — start clean instead).
|
|
228
|
+
*/
|
|
229
|
+
export function buildSeededPiTranscript(
|
|
230
|
+
jsonl: string,
|
|
231
|
+
opts: { cwd: string; seedTokens: number; sourcePath?: string },
|
|
232
|
+
): SeededPiTranscript | null {
|
|
233
|
+
const p = parsePiJsonl(jsonl, false);
|
|
234
|
+
if (!p) return null;
|
|
235
|
+
const h = splitPiHeader(p.parsed, p.raws);
|
|
236
|
+
if (!h) return null;
|
|
237
|
+
const startIdx = tailStartByBudget(h.entries, h.entryRaws, opts.seedTokens);
|
|
238
|
+
if (startIdx < 0) return null;
|
|
239
|
+
return emitPiTail(h.header, h.entries, h.entryRaws, startIdx, opts.cwd, opts.sourcePath);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A pinned cut point for rotation: where the verbatim tail begins. */
|
|
243
|
+
export type PiSeedCut = { boundaryId: string; boundaryTimestamp: string | null };
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Compute the cut point (the first-kept turn's identity) for a `seedTokens` budget,
|
|
247
|
+
* from raw jsonl. Parsed leniently because the live transcript may still be mid-write
|
|
248
|
+
* when this runs at warn time. Returns null if there's no genuine turn or the chosen
|
|
249
|
+
* boundary entry lacks an id.
|
|
250
|
+
*/
|
|
251
|
+
export function computePiSeedCut(jsonl: string, seedTokens: number): PiSeedCut | null {
|
|
252
|
+
const p = parsePiJsonl(jsonl, true);
|
|
253
|
+
if (!p) return null;
|
|
254
|
+
const h = splitPiHeader(p.parsed, p.raws);
|
|
255
|
+
if (!h) return null;
|
|
256
|
+
const startIdx = tailStartByBudget(h.entries, h.entryRaws, seedTokens);
|
|
257
|
+
if (startIdx < 0) return null;
|
|
258
|
+
const boundary = h.entries[startIdx];
|
|
259
|
+
if (typeof boundary.id !== "string") return null;
|
|
260
|
+
return {
|
|
261
|
+
boundaryId: boundary.id,
|
|
262
|
+
boundaryTimestamp: typeof boundary.timestamp === "string" ? boundary.timestamp : null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the seeded transcript starting at a pinned boundary entry id (the whole tail
|
|
268
|
+
* from that entry through EOF). Used by rotation to honor the cut point the mind was
|
|
269
|
+
* warned about, regardless of how much the transcript grew after the warning.
|
|
270
|
+
* Returns null if the boundary id isn't found or a line is corrupt.
|
|
271
|
+
*/
|
|
272
|
+
export function buildSeededPiTranscriptFromCut(
|
|
273
|
+
jsonl: string,
|
|
274
|
+
opts: { cwd: string; boundaryId: string; sourcePath?: string },
|
|
275
|
+
): SeededPiTranscript | null {
|
|
276
|
+
const p = parsePiJsonl(jsonl, false);
|
|
277
|
+
if (!p) return null;
|
|
278
|
+
const h = splitPiHeader(p.parsed, p.raws);
|
|
279
|
+
if (!h) return null;
|
|
280
|
+
const startIdx = h.entries.findIndex((e) => e.id === opts.boundaryId);
|
|
281
|
+
if (startIdx < 0) return null;
|
|
282
|
+
return emitPiTail(h.header, h.entries, h.entryRaws, startIdx, opts.cwd, opts.sourcePath);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* True if `<piSessionsDir>/<name>/` already holds a live pi `.jsonl` session —
|
|
287
|
+
* in which case continueRecent will resume it and we must not seed over it.
|
|
288
|
+
*/
|
|
289
|
+
export function hasLivePiSession(piSessionsDir: string, name: string): boolean {
|
|
290
|
+
try {
|
|
291
|
+
return readdirSync(resolve(piSessionsDir, name)).some((f) => f.endsWith(".jsonl"));
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Result of a successful pi seed: the new session id and when the source was archived. */
|
|
298
|
+
export type SeededPiOutcome = { sessionId: string; archivedAt: number | null };
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Seed a fresh persistent pi session from the mind's previous archived transcript.
|
|
302
|
+
* Writes the synthetic session file into `<piSessionsDir>/<name>/` and returns the
|
|
303
|
+
* new session id (the header id continueRecent will adopt) plus the archived-at
|
|
304
|
+
* time (for the gap note), or null if there's nothing to seed. Never throws — any
|
|
305
|
+
* failure returns null so session start is never blocked.
|
|
306
|
+
*/
|
|
307
|
+
export function seedPiSession(opts: {
|
|
308
|
+
cwd: string;
|
|
309
|
+
piSessionsDir: string;
|
|
310
|
+
name: string;
|
|
311
|
+
seedTokens: number;
|
|
312
|
+
}): SeededPiOutcome | null {
|
|
313
|
+
const { cwd, piSessionsDir, name, seedTokens } = opts;
|
|
314
|
+
// Ephemeral `new-*` sessions are never persisted or archived, so they never
|
|
315
|
+
// seed. The agent caller already gates on this; guard here too so the invariant
|
|
316
|
+
// holds wherever seedPiSession is called.
|
|
317
|
+
if (name.startsWith("new-")) return null;
|
|
318
|
+
if (seedTokens <= 0) return null; // seeding disabled
|
|
319
|
+
// A live session already exists — let continueRecent resume it, don't seed.
|
|
320
|
+
if (hasLivePiSession(piSessionsDir, name)) return null;
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const archived = findLatestArchivedPiSession(piSessionsDir, name);
|
|
324
|
+
if (!archived) return null;
|
|
325
|
+
|
|
326
|
+
// findPiSessionFile picks the latest `.jsonl` in `<base>/<subdir>`; here the
|
|
327
|
+
// subdir is the archived `<name>-<ts>` directory we just located.
|
|
328
|
+
const sourcePath = findPiSessionFile(resolve(piSessionsDir, "archive"), basename(archived.dir));
|
|
329
|
+
if (!sourcePath) return null; // no transcript survived archival — start clean
|
|
330
|
+
|
|
331
|
+
const seeded = buildSeededPiTranscript(readFileSync(sourcePath, "utf-8"), {
|
|
332
|
+
cwd,
|
|
333
|
+
seedTokens,
|
|
334
|
+
sourcePath,
|
|
335
|
+
});
|
|
336
|
+
if (!seeded) return null;
|
|
337
|
+
|
|
338
|
+
const destDir = resolve(piSessionsDir, name);
|
|
339
|
+
mkdirSync(destDir, { recursive: true });
|
|
340
|
+
const fileTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
341
|
+
const destPath = resolve(destDir, `${fileTimestamp}_${seeded.sessionId}.jsonl`);
|
|
342
|
+
writeFileSync(destPath, `${seeded.lines.join("\n")}\n`);
|
|
343
|
+
log(
|
|
344
|
+
"mind",
|
|
345
|
+
`session "${name}": seeded ${seeded.lines.length - 1} entr(ies) from ${sourcePath} → ${seeded.sessionId}`,
|
|
346
|
+
);
|
|
347
|
+
return { sessionId: seeded.sessionId, archivedAt: archived.archivedAt };
|
|
348
|
+
} catch (err) {
|
|
349
|
+
log("mind", `session "${name}": seeding failed, starting fresh:`, err);
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Archive-directory timestamp for a rotated-out pi session, matching the daemon
|
|
356
|
+
* sleep-manager's archiveSessions: `toISOString().replace(/[:.]/g,"-").slice(0,16)`
|
|
357
|
+
* → UTC `YYYY-MM-DDTHH-MM`.
|
|
358
|
+
*/
|
|
359
|
+
export function archivePiSessionTimestamp(now: Date = new Date()): string {
|
|
360
|
+
return now.toISOString().replace(/[:.]/g, "-").slice(0, 16);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Relocate the rotated-out session file into `<piSessionsDir>/archive/<name>-<ts>/`,
|
|
365
|
+
* mirroring how sleep archival preserves pi sessions (a `<name>-<ts>/` directory of
|
|
366
|
+
* jsonl files — pi archives whole directories, not pointer files). Keeps the full
|
|
367
|
+
* transcript findable and leaves the live dir holding only the new session.
|
|
368
|
+
*/
|
|
369
|
+
function archiveRotatedPiFile(
|
|
370
|
+
piSessionsDir: string,
|
|
371
|
+
name: string,
|
|
372
|
+
sourcePath: string,
|
|
373
|
+
now: Date = new Date(),
|
|
374
|
+
): void {
|
|
375
|
+
const dest = resolve(piSessionsDir, "archive", `${name}-${archivePiSessionTimestamp(now)}`);
|
|
376
|
+
mkdirSync(dest, { recursive: true });
|
|
377
|
+
renameSync(sourcePath, resolve(dest, basename(sourcePath)));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Rotate a pi session in place at the context limit. Reads the live session file,
|
|
382
|
+
* builds a seeded tail from the pinned `cut` (falling back to a budget-based tail
|
|
383
|
+
* when the pin can't be honored), writes it as a new session file in the same live
|
|
384
|
+
* dir, and — for persistent sessions — archives the rotated-out file so the full
|
|
385
|
+
* transcript stays findable. Returns the new session file **path** (the caller
|
|
386
|
+
* switches the running SessionManager to it), or null if rotation can't proceed
|
|
387
|
+
* (the caller then falls back to a fresh session). Never throws.
|
|
388
|
+
*/
|
|
389
|
+
export function rotatePiSession(opts: {
|
|
390
|
+
cwd: string;
|
|
391
|
+
sessionsDir: string;
|
|
392
|
+
name: string;
|
|
393
|
+
sourcePath: string;
|
|
394
|
+
cut: PiSeedCut | null;
|
|
395
|
+
seedTokens: number;
|
|
396
|
+
}): string | null {
|
|
397
|
+
const { cwd, sessionsDir, name, sourcePath, cut, seedTokens } = opts;
|
|
398
|
+
try {
|
|
399
|
+
const jsonl = readFileSync(sourcePath, "utf-8");
|
|
400
|
+
// Honor the pinned boundary the mind was warned about; only fall back to a
|
|
401
|
+
// budget-based tail if the pin can't be resolved (missing/renamed id).
|
|
402
|
+
const seeded =
|
|
403
|
+
(cut
|
|
404
|
+
? buildSeededPiTranscriptFromCut(jsonl, { cwd, boundaryId: cut.boundaryId, sourcePath })
|
|
405
|
+
: null) ?? buildSeededPiTranscript(jsonl, { cwd, seedTokens, sourcePath });
|
|
406
|
+
if (!seeded) return null;
|
|
407
|
+
|
|
408
|
+
const liveDir = resolve(sessionsDir, name);
|
|
409
|
+
mkdirSync(liveDir, { recursive: true });
|
|
410
|
+
const fileTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
411
|
+
const destPath = resolve(liveDir, `${fileTimestamp}_${seeded.sessionId}.jsonl`);
|
|
412
|
+
writeFileSync(destPath, `${seeded.lines.join("\n")}\n`);
|
|
413
|
+
|
|
414
|
+
// Archive the rotated-out session (mirrors sleep archival's dir layout). A
|
|
415
|
+
// failure here is non-fatal — the new file is already live; the stale old file
|
|
416
|
+
// just lingers (continueRecent still picks the newer one). Ephemeral `new-*`
|
|
417
|
+
// sessions never reach here (they're inMemory, with no source file).
|
|
418
|
+
if (!name.startsWith("new-")) {
|
|
419
|
+
try {
|
|
420
|
+
archiveRotatedPiFile(sessionsDir, name, sourcePath);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
log("mind", `session "${name}": archiving rotated-out file failed:`, err);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
log(
|
|
426
|
+
"mind",
|
|
427
|
+
`session "${name}": rotated ${basename(sourcePath)} → ${seeded.sessionId} (${seeded.lines.length - 1} entries)`,
|
|
428
|
+
);
|
|
429
|
+
return destPath;
|
|
430
|
+
} catch (err) {
|
|
431
|
+
log("mind", `session "${name}": rotation failed:`, err);
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The honest-boundary note injected on the first prompt/turn of a seeded session
|
|
3
|
+
* (see the session seeders), plus the gap-duration clause the seeders enrich it
|
|
4
|
+
* with. Shared across the claude, codex, and pi templates so the text stays
|
|
5
|
+
* identical apart from the computed gap.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why a session is presenting a seeded tail. `"restored"` — resumed from a
|
|
10
|
+
* previous session's archived transcript (sleep/wake, restart). `"rotation"` —
|
|
11
|
+
* rotated in place at the context limit, earlier turns collapsed. Each gets its
|
|
12
|
+
* own note so the reader knows which boundary they've crossed.
|
|
13
|
+
*/
|
|
14
|
+
export type SeedCause = "restored" | "rotation";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base note for the "restored" cause, used verbatim when the gap can't be computed.
|
|
18
|
+
* buildSeededNote() enriches the parenthetical with a coarse gap-duration clause.
|
|
19
|
+
*/
|
|
20
|
+
export const SEEDED_SESSION_NOTE_BASE =
|
|
21
|
+
"Note: this session continues from your previous session's transcript (restored after archival). The conversation above happened before the break; a fresh session begins here.";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Note for the "rotation" cause. No gap clause (the break is instantaneous — the
|
|
25
|
+
* context limit, not a sleep). Points at `volute mind history` for the collapsed
|
|
26
|
+
* turns, whose summaries the mind was asked to author at warn time.
|
|
27
|
+
*/
|
|
28
|
+
export const ROTATED_SESSION_NOTE =
|
|
29
|
+
"Note: this session was rotated in place at the context limit. The turns above are your most recent, kept verbatim; earlier turns have been collapsed — your summaries of them are in your history (`volute mind history`). A fresh session continues from here.";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse an archive-pointer timestamp into epoch millis, or null if it doesn't
|
|
33
|
+
* match. The daemon's sleep-manager (archiveSessions) formats it as
|
|
34
|
+
* `new Date().toISOString().replace(/[:.]/g, "-").slice(0, 16)` → `YYYY-MM-DDTHH-MM`,
|
|
35
|
+
* which is **UTC** and minute-precision, so we parse it back as UTC.
|
|
36
|
+
*/
|
|
37
|
+
export function parseArchiveTimestamp(ts: string): number | null {
|
|
38
|
+
const m = ts.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})$/);
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
|
|
41
|
+
return Number.isNaN(ms) ? null : ms;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Coarse human phrasing of a gap in millis: minutes, then hours, then days. */
|
|
45
|
+
function formatGap(ms: number): string | null {
|
|
46
|
+
if (ms < 0) return null;
|
|
47
|
+
const minutes = Math.round(ms / 60_000);
|
|
48
|
+
if (minutes < 1) return "less than a minute";
|
|
49
|
+
if (minutes < 60) return `about ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
50
|
+
const hours = Math.round(ms / 3_600_000);
|
|
51
|
+
if (hours < 24) return `about ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
52
|
+
const days = Math.round(ms / 86_400_000);
|
|
53
|
+
return `about ${days} day${days === 1 ? "" : "s"}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the honest-boundary note for a seeded session, chosen by `cause`:
|
|
58
|
+
* - "rotation" → the rotation note (no gap clause).
|
|
59
|
+
* - "restored" (default) → the restored note, enriched with a coarse
|
|
60
|
+
* gap-duration clause when `archivedAtMs` is known (epoch millis from
|
|
61
|
+
* parseArchiveTimestamp(); null → no clause). `nowMs` defaults to now so the
|
|
62
|
+
* gap reflects when the mind actually reads the note.
|
|
63
|
+
*/
|
|
64
|
+
export function buildSeededNote(opts: {
|
|
65
|
+
cause?: SeedCause;
|
|
66
|
+
archivedAtMs?: number | null;
|
|
67
|
+
nowMs?: number;
|
|
68
|
+
}): string {
|
|
69
|
+
if (opts.cause === "rotation") return ROTATED_SESSION_NOTE;
|
|
70
|
+
const archivedAtMs = opts.archivedAtMs ?? null;
|
|
71
|
+
if (archivedAtMs == null) return SEEDED_SESSION_NOTE_BASE;
|
|
72
|
+
const gap = formatGap((opts.nowMs ?? Date.now()) - archivedAtMs);
|
|
73
|
+
if (!gap) return SEEDED_SESSION_NOTE_BASE;
|
|
74
|
+
return SEEDED_SESSION_NOTE_BASE.replace(
|
|
75
|
+
"(restored after archival)",
|
|
76
|
+
`(restored after archival; the break lasted ${gap})`,
|
|
77
|
+
);
|
|
78
|
+
}
|