@geminixiang/mikan 1.0.0-beta.47 → 1.0.0-beta.49

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 (78) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/adapters/discord/context.d.ts.map +1 -1
  3. package/dist/adapters/discord/context.js +2 -6
  4. package/dist/adapters/discord/context.js.map +1 -1
  5. package/dist/adapters/telegram/context.d.ts.map +1 -1
  6. package/dist/adapters/telegram/context.js +2 -4
  7. package/dist/adapters/telegram/context.js.map +1 -1
  8. package/dist/agent/presenter.d.ts +3 -10
  9. package/dist/agent/presenter.d.ts.map +1 -1
  10. package/dist/agent/presenter.js +35 -32
  11. package/dist/agent/presenter.js.map +1 -1
  12. package/dist/agent/runner.d.ts.map +1 -1
  13. package/dist/agent/runner.js +16 -22
  14. package/dist/agent/runner.js.map +1 -1
  15. package/dist/agent/types.d.ts +4 -6
  16. package/dist/agent/types.d.ts.map +1 -1
  17. package/dist/agent/types.js.map +1 -1
  18. package/dist/cli/boot.js +1 -1
  19. package/dist/cli/boot.js.map +1 -1
  20. package/dist/cli/sessions.d.ts.map +1 -1
  21. package/dist/cli/sessions.js +18 -13
  22. package/dist/cli/sessions.js.map +1 -1
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +4 -2
  25. package/dist/config.js.map +1 -1
  26. package/dist/env-manifest.js +2 -2
  27. package/dist/env-manifest.js.map +1 -1
  28. package/dist/harness/models.d.ts.map +1 -1
  29. package/dist/harness/models.js +1 -4
  30. package/dist/harness/models.js.map +1 -1
  31. package/dist/harness/runner.d.ts.map +1 -1
  32. package/dist/harness/runner.js +2 -2
  33. package/dist/harness/runner.js.map +1 -1
  34. package/dist/harness/session-store.d.ts +3 -45
  35. package/dist/harness/session-store.d.ts.map +1 -1
  36. package/dist/harness/session-store.js +293 -198
  37. package/dist/harness/session-store.js.map +1 -1
  38. package/dist/harness/subagent-runner.d.ts.map +1 -1
  39. package/dist/harness/subagent-runner.js +1 -8
  40. package/dist/harness/subagent-runner.js.map +1 -1
  41. package/dist/harness/types.d.ts +11 -11
  42. package/dist/harness/types.d.ts.map +1 -1
  43. package/dist/harness/types.js.map +1 -1
  44. package/dist/main.js +5 -0
  45. package/dist/main.js.map +1 -1
  46. package/dist/mcp/catalog.d.ts.map +1 -1
  47. package/dist/mcp/catalog.js +0 -88
  48. package/dist/mcp/catalog.js.map +1 -1
  49. package/dist/mcp/open-connector.d.ts +1 -1
  50. package/dist/mcp/open-connector.d.ts.map +1 -1
  51. package/dist/mcp/open-connector.js +23 -22
  52. package/dist/mcp/open-connector.js.map +1 -1
  53. package/dist/runtime/conversation-runtime.js +1 -0
  54. package/dist/runtime/conversation-runtime.js.map +1 -1
  55. package/dist/runtime/session-lifecycle.d.ts +0 -1
  56. package/dist/runtime/session-lifecycle.d.ts.map +1 -1
  57. package/dist/runtime/session-lifecycle.js +1 -4
  58. package/dist/runtime/session-lifecycle.js.map +1 -1
  59. package/dist/runtime/types.d.ts +3 -0
  60. package/dist/runtime/types.d.ts.map +1 -1
  61. package/dist/runtime/types.js.map +1 -1
  62. package/dist/sessions/migrate-pi-084.d.ts +8 -0
  63. package/dist/sessions/migrate-pi-084.d.ts.map +1 -0
  64. package/dist/sessions/migrate-pi-084.js +353 -0
  65. package/dist/sessions/migrate-pi-084.js.map +1 -0
  66. package/dist/sessions/migrate-v3.d.ts +0 -5
  67. package/dist/sessions/migrate-v3.d.ts.map +1 -1
  68. package/dist/sessions/migrate-v3.js +129 -51
  69. package/dist/sessions/migrate-v3.js.map +1 -1
  70. package/dist/sessions/types.d.ts +6 -1
  71. package/dist/sessions/types.d.ts.map +1 -1
  72. package/dist/sessions/types.js.map +1 -1
  73. package/dist/types.d.ts +5 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js.map +1 -1
  76. package/dist/web/session-view/portal.js +0 -16
  77. package/dist/web/session-view/portal.js.map +1 -1
  78. package/package.json +3 -3
@@ -1,25 +1,20 @@
1
1
  /**
2
- * mikan-owned session storage facade over pi-agent-core's v4 sessions.
2
+ * mikan-owned facade over Pi's current JSONL v4 SessionRepo.
3
3
  *
4
- * Sessions are pi v4 JSONL files: a `{"kind":"header","version":4,...}` line
5
- * followed by mutation lines (entries, lane pointers, facts, lane records).
6
- * pi's `Session`/`JsonlSessionStorage` own the format, tree validation, and
7
- * torn-tail repair; this facade owns mikan's concerns only: session files
8
- * live at mikan-chosen paths (session-key layout), mikan header extras ride
9
- * in the v4 header `metadata`, and a missing file behaves as an empty
10
- * session whose header materializes on the first append.
11
- *
12
- * v3 files (the pre-0.84 layout) are not readable at runtime: `open` throws
13
- * an actionable error pointing at the `mikan sessions migrate` script.
4
+ * Pi owns entry, branch, value, transaction, and storage validation. mikan
5
+ * owns exact file paths, single-writer leases, lazy materialization, lineage
6
+ * metadata, and the stable inspection API consumed by platform runtimes.
7
+ * Older session generations are rejected and converted only by the offline
8
+ * `mikan sessions migrate` command.
14
9
  */
15
10
  import { closeSync, existsSync, fstatSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
16
11
  import { tmpdir } from "node:os";
17
12
  import { basename, dirname, join, resolve } from "node:path";
18
- import { buildSessionContext as buildContextFromEntries, createCustomMessage, InMemorySessionStorage, JsonlSessionRepo, Session, uuidv7, } from "@earendil-works/pi-agent-core";
13
+ import { branchTip, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, insertEntry, JsonlSessionRepo, MemorySessionRepo, setValue, TODO_CONTEXT, uuidv7, value, } from "@earendil-works/pi-agent-core";
19
14
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
20
15
  import { atomicWritePrivateFile } from "../utils/file-guards.js";
21
- import { CURRENT_SESSION_VERSION, } from "./types.js";
22
- /** Live writer claims: lease key → canonical path it was claimed for. */
16
+ import { CURRENT_SESSION_VERSION } from "./types.js";
17
+ const MIKAN_METADATA = value("mikan", "metadata");
23
18
  const activeWriterKeys = new Map();
24
19
  function canonicalSessionPath(path) {
25
20
  const absolute = resolve(path);
@@ -32,21 +27,10 @@ function canonicalSessionPath(path) {
32
27
  function sessionWriterKey(path) {
33
28
  if (!existsSync(path))
34
29
  return `path:${path}`;
35
- const stat = statSync(path);
36
- // birthtime disambiguates a deleted-and-recreated file that received the
37
- // same reused inode (same path or not). Filesystems without birthtime
38
- // report 0 and fall back to dev:ino alone.
39
- return `inode:${stat.dev}:${stat.ino}:${stat.birthtimeMs}`;
30
+ const stats = statSync(path);
31
+ return `inode:${stats.dev}:${stats.ino}:${stats.birthtimeMs}`;
40
32
  }
41
- /**
42
- * A claim is stale when the file it was taken for no longer resolves to the
43
- * claimed identity — deleted or replaced since. Filesystems reuse inodes, so
44
- * without this check a leaked claim for a deleted file would block an
45
- * unrelated new file that happens to receive the same dev:ino.
46
- */
47
33
  function isStaleClaim(key, claimedPath) {
48
- // Pending (`path:`) claims guard a file that legitimately does not exist
49
- // yet, so absence proves nothing; they are never stale.
50
34
  if (!key.startsWith("inode:"))
51
35
  return false;
52
36
  return sessionWriterKey(claimedPath) !== key;
@@ -75,31 +59,13 @@ function promotePendingWriter(pathKey, path) {
75
59
  activeWriterKeys.delete(pathKey);
76
60
  return inodeKey;
77
61
  }
78
- let sharedRepo;
79
- /**
80
- * Drop undefined-valued properties before durable persistence. pi's agent
81
- * loop builds messages with explicit `details: undefined` / `usage: undefined`
82
- * (e.g. tool results from tools that return only `content`), which pi's own
83
- * v4 durable-payload validator rejects; persistence is a value boundary, so
84
- * a JSON round-trip matches the validator's semantics exactly.
85
- */
86
- function toDurable(value) {
87
- return value === undefined ? value : JSON.parse(JSON.stringify(value));
88
- }
89
- function repo() {
90
- // The repo is only used to open sessions at explicit paths; its
91
- // sessionsRoot layout is never consulted, so one shared instance is fine.
92
- sharedRepo ??= new JsonlSessionRepo({
93
- fs: new NodeExecutionEnv({ cwd: process.cwd() }),
94
- sessionsRoot: process.cwd(),
95
- });
96
- return sharedRepo;
62
+ function toDurable(input) {
63
+ return input === undefined ? input : JSON.parse(JSON.stringify(input));
97
64
  }
98
65
  class SessionFormatError extends Error {
99
66
  }
100
67
  const HEADER_CHUNK_SIZE = 4096;
101
68
  const MAX_HEADER_BYTES = 1024 * 1024;
102
- /** Read only the first JSONL line rather than loading the complete session. */
103
69
  function readHeaderLine(filePath) {
104
70
  const fd = openSync(filePath, "r");
105
71
  const chunks = [];
@@ -126,23 +92,28 @@ function readHeaderLine(filePath) {
126
92
  }
127
93
  return Buffer.concat(chunks).toString("utf-8");
128
94
  }
129
- /** Parse the first line of a session file into a v4 header, or explain why not. */
130
- function parseV4Header(filePath, firstLine) {
131
- firstLine = firstLine.trim();
132
- if (!firstLine)
95
+ function parseCurrentHeader(filePath, firstLine) {
96
+ const trimmed = firstLine.trim();
97
+ if (!trimmed)
133
98
  throw new SessionFormatError(`Session file has a blank header line: ${filePath}`);
134
99
  let parsed;
135
100
  try {
136
- parsed = JSON.parse(firstLine);
101
+ parsed = JSON.parse(trimmed);
137
102
  }
138
103
  catch {
139
104
  throw new SessionFormatError(`Session file header is not valid JSON: ${filePath}`);
140
105
  }
141
106
  const record = parsed;
142
- if (record.type === "session") {
143
- throw new SessionFormatError(`Session file uses the legacy v3 format: ${filePath}. Run \`mikan sessions migrate\` before starting this mikan version.`);
144
- }
145
- if (record.kind !== "header" || record.version !== 4 || typeof record.id !== "string") {
107
+ if (record.type === "session" || (record.kind === "header" && record.version === 4)) {
108
+ const generation = record.type === "session" ? "legacy v3" : "Pi 0.84 v4";
109
+ throw new SessionFormatError(`Session file uses the ${generation} format: ${filePath}. Run \`mikan sessions migrate\` before starting this mikan version.`);
110
+ }
111
+ if (record.kind !== "header" ||
112
+ record.v !== 4 ||
113
+ record.storageVersion !== 1 ||
114
+ typeof record.id !== "string" ||
115
+ typeof record.createdAt !== "number" ||
116
+ typeof record.cwd !== "string") {
146
117
  throw new SessionFormatError(`Session file has an unrecognized header: ${filePath}`);
147
118
  }
148
119
  return parsed;
@@ -151,23 +122,57 @@ function metadataFromHeader(header, path) {
151
122
  return {
152
123
  id: header.id,
153
124
  createdAt: header.createdAt,
125
+ storageVersion: header.storageVersion,
154
126
  cwd: header.cwd,
155
127
  path,
156
128
  modifiedAt: 0,
157
- sourceFormat: 4,
158
129
  ...(header.parentSessionId !== undefined ? { parentSessionId: header.parentSessionId } : {}),
159
130
  ...(header.legacyParentSessionPath !== undefined
160
131
  ? { legacyParentSessionPath: header.legacyParentSessionPath }
161
132
  : {}),
162
- ...(header.metadata !== undefined ? { metadata: header.metadata } : {}),
163
133
  };
164
134
  }
135
+ function parseMikanMetadata(filePath) {
136
+ let current;
137
+ const lines = readFileSync(filePath, "utf-8").split("\n");
138
+ for (const [index, line] of lines.entries()) {
139
+ if (index === 0 || line.trim() === "")
140
+ continue;
141
+ let transaction;
142
+ try {
143
+ transaction = JSON.parse(line);
144
+ }
145
+ catch {
146
+ if (index === lines.length - 1)
147
+ break;
148
+ continue;
149
+ }
150
+ const writes = Array.isArray(transaction) ? transaction : [transaction];
151
+ for (const candidate of writes) {
152
+ if (typeof candidate !== "object" || candidate === null)
153
+ continue;
154
+ const write = candidate;
155
+ if (write.kind !== "value" || write.namespace !== "mikan" || write.key !== "metadata") {
156
+ continue;
157
+ }
158
+ if (write.op === "delete")
159
+ current = undefined;
160
+ else if (write.op === "set" &&
161
+ typeof write.value === "object" &&
162
+ write.value !== null &&
163
+ !Array.isArray(write.value)) {
164
+ current = write.value;
165
+ }
166
+ }
167
+ }
168
+ return current;
169
+ }
165
170
  function fileFingerprint(path) {
166
171
  const bytes = readFileSync(path);
167
172
  const fd = openSync(path, "r");
168
173
  try {
169
- const stat = fstatSync(fd);
170
- return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${bytes.toString("base64")}`;
174
+ const stats = fstatSync(fd);
175
+ return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${bytes.toString("base64")}`;
171
176
  }
172
177
  finally {
173
178
  closeSync(fd);
@@ -176,6 +181,7 @@ function fileFingerprint(path) {
176
181
  function materializePendingFile(path, header, expectedFingerprint) {
177
182
  const content = `${JSON.stringify(header)}\n`;
178
183
  if (expectedFingerprint === null) {
184
+ mkdirSync(dirname(path), { recursive: true });
179
185
  const fd = openSync(path, "wx", 0o600);
180
186
  try {
181
187
  writeFileSync(fd, content);
@@ -187,10 +193,10 @@ function materializePendingFile(path, header, expectedFingerprint) {
187
193
  }
188
194
  const fd = openSync(path, "r+");
189
195
  try {
190
- const stat = fstatSync(fd);
191
- const bytes = Buffer.alloc(stat.size);
196
+ const stats = fstatSync(fd);
197
+ const bytes = Buffer.alloc(stats.size);
192
198
  readSync(fd, bytes, 0, bytes.length, 0);
193
- const actual = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${bytes.toString("base64")}`;
199
+ const actual = `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${bytes.toString("base64")}`;
194
200
  if (actual !== expectedFingerprint) {
195
201
  throw new Error(`Session file changed before pending session materialization: ${path}`);
196
202
  }
@@ -202,22 +208,31 @@ function materializePendingFile(path, header, expectedFingerprint) {
202
208
  }
203
209
  }
204
210
  function buildHeader(cwd, options) {
211
+ const metadata = options?.parentSession === undefined
212
+ ? undefined
213
+ : { parentSessionPath: options.parentSession };
205
214
  return {
206
- kind: "header",
207
- version: 4,
208
- id: options?.id ?? uuidv7(),
209
- createdAt: Date.now(),
210
- cwd,
211
- ...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
212
- // The v3 parent *path* has no dedicated v4 slot when the id is known;
213
- // preserve it in metadata so lineage debugging keeps working.
214
- ...(options?.parentSession !== undefined
215
- ? { metadata: { parentSessionPath: options.parentSession } }
216
- : {}),
215
+ header: {
216
+ v: 4,
217
+ kind: "header",
218
+ id: options?.id ?? uuidv7(),
219
+ storageVersion: 1,
220
+ createdAt: Date.now(),
221
+ cwd,
222
+ ...(options?.parentSessionId !== undefined
223
+ ? { parentSessionId: options.parentSessionId }
224
+ : {}),
225
+ ...(options?.parentSession !== undefined
226
+ ? { legacyParentSessionPath: options.parentSession }
227
+ : {}),
228
+ },
229
+ ...(metadata !== undefined ? { metadata } : {}),
217
230
  };
218
231
  }
219
- function headerView(header) {
220
- const metadata = header.metadata;
232
+ function headerView(header, metadata) {
233
+ const parentSession = typeof metadata?.parentSessionPath === "string"
234
+ ? metadata.parentSessionPath
235
+ : header.legacyParentSessionPath;
221
236
  return {
222
237
  type: "session",
223
238
  version: CURRENT_SESSION_VERSION,
@@ -225,124 +240,175 @@ function headerView(header) {
225
240
  timestamp: new Date(header.createdAt).toISOString(),
226
241
  cwd: header.cwd,
227
242
  ...(header.parentSessionId !== undefined ? { parentSessionId: header.parentSessionId } : {}),
228
- ...(typeof metadata?.parentSessionPath === "string"
229
- ? { parentSession: metadata.parentSessionPath }
230
- : {}),
231
- ...(header.legacyParentSessionPath !== undefined && metadata?.parentSessionPath === undefined
232
- ? { parentSession: header.legacyParentSessionPath }
233
- : {}),
243
+ ...(parentSession !== undefined ? { parentSession } : {}),
234
244
  ...(metadata !== undefined ? { metadata } : {}),
235
245
  };
236
246
  }
237
- class ReadOnlySessionInspection {
238
- constructor(header, session) {
247
+ async function openFileSession(path, header) {
248
+ const sessionRepo = new JsonlSessionRepo({
249
+ fileSystem: new NodeExecutionEnv({ cwd: process.cwd() }),
250
+ sessionsRoot: dirname(path),
251
+ });
252
+ try {
253
+ const session = await sessionRepo.open(metadataFromHeader(header, path), TODO_CONTEXT);
254
+ return { session, repo: sessionRepo };
255
+ }
256
+ catch (error) {
257
+ await sessionRepo.close(TODO_CONTEXT);
258
+ throw error;
259
+ }
260
+ }
261
+ async function mainBranch(session, create) {
262
+ const existing = await session.branch("main", TODO_CONTEXT);
263
+ if (existing || !create)
264
+ return existing;
265
+ return session.createBranch("main", null, TODO_CONTEXT);
266
+ }
267
+ function isContextMessage(message) {
268
+ return (message.role !== "assistant" ||
269
+ (message.stopReason !== "error" &&
270
+ message.stopReason !== "aborted" &&
271
+ message.stopReason !== "deferred"));
272
+ }
273
+ function buildContext(entries) {
274
+ const compactionIndex = entries.findLastIndex((entry) => entry.type === "compaction");
275
+ const visibleEntries = compactionIndex === -1
276
+ ? entries
277
+ : [entries[compactionIndex], ...entries.slice(compactionIndex + 1)];
278
+ const messages = [];
279
+ for (const entry of visibleEntries) {
280
+ if (entry.type === "message") {
281
+ if (isContextMessage(entry.message))
282
+ messages.push(entry.message);
283
+ }
284
+ else if (entry.type === "compaction") {
285
+ messages.push(createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), ...entry.retainedTail.filter(isContextMessage));
286
+ }
287
+ else if (entry.type === "branch_summary" && entry.summary) {
288
+ messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
289
+ }
290
+ }
291
+ return { messages };
292
+ }
293
+ class CachedSessionInspection {
294
+ constructor(header, entries, name, branch, context) {
239
295
  this.header = header;
240
- this.session = session;
296
+ this.entries = entries;
297
+ this.name = name;
298
+ this.branch = branch;
299
+ this.context = context;
241
300
  }
242
301
  getHeader() {
243
- return headerView(this.header);
302
+ return structuredClone(this.header);
244
303
  }
245
304
  async getEntries() {
246
- return this.session.findEntries({ order: "oldestFirst" });
305
+ return structuredClone(this.entries);
247
306
  }
248
307
  async getSessionName() {
249
- return this.session.getName();
308
+ return this.name;
250
309
  }
251
310
  async getBranch(fromId) {
252
- return this.session.findEntriesOnBranch({
253
- order: "oldestFirst",
254
- ...(fromId !== undefined ? { start: fromId } : {}),
255
- });
311
+ if (fromId === undefined)
312
+ return structuredClone(this.branch);
313
+ const byId = new Map(this.entries.map((entry) => [entry.id, entry]));
314
+ const result = [];
315
+ let current = byId.get(fromId);
316
+ while (current) {
317
+ result.push(current);
318
+ current = current.parentId === null ? undefined : byId.get(current.parentId);
319
+ }
320
+ return result.toReversed();
256
321
  }
257
322
  async buildSessionContext() {
258
- return buildContextFromEntries(await this.getBranch());
323
+ return structuredClone(this.context);
259
324
  }
260
325
  }
261
- /**
262
- * Async session store over one pi v4 JSONL file at a mikan-chosen path.
263
- *
264
- * A missing or empty file opens as an empty session; the header (and file)
265
- * materialize on the first append. Files with content must carry a valid
266
- * v4 header — v3 files throw with a pointer at the migration script.
267
- */
268
326
  export class SessionStore {
269
327
  constructor(sessionFile, cwd, state, writerKey) {
270
- this.mutationTail = Promise.resolve();
271
- this.closed = false;
272
328
  this.sessionFile = sessionFile;
273
329
  this.cwd = cwd;
274
330
  this.state = state;
275
331
  this.writerKey = writerKey;
332
+ this.mutationTail = Promise.resolve();
333
+ this.closed = false;
276
334
  }
277
- /**
278
- * Open a session file. Missing and empty files start an empty session
279
- * whose header is written on the first append.
280
- * @param cwdOverride Working directory override; defaults to the header cwd.
281
- */
282
335
  static async open(path, cwdOverride) {
283
336
  const writer = acquireWriter(path);
284
337
  const writerPath = writer.path;
285
338
  try {
286
339
  if (!existsSync(writerPath)) {
287
340
  const cwd = cwdOverride ?? process.cwd();
288
- return new SessionStore(writerPath, cwd, { kind: "pending", header: buildHeader(cwd), fingerprint: null }, writer.key);
341
+ const built = buildHeader(cwd);
342
+ return new SessionStore(writerPath, cwd, { kind: "pending", ...built, fingerprint: null }, writer.key);
289
343
  }
290
344
  const firstLine = readHeaderLine(writerPath);
291
345
  if (firstLine.trim().length === 0) {
292
346
  const cwd = cwdOverride ?? process.cwd();
293
- return new SessionStore(writerPath, cwd, {
294
- kind: "pending",
295
- header: buildHeader(cwd),
296
- fingerprint: fileFingerprint(writerPath),
297
- }, writer.key);
347
+ const built = buildHeader(cwd);
348
+ return new SessionStore(writerPath, cwd, { kind: "pending", ...built, fingerprint: fileFingerprint(writerPath) }, writer.key);
298
349
  }
299
- const header = parseV4Header(writerPath, firstLine);
300
- const session = await repo().open(metadataFromHeader(header, writerPath));
301
- return new SessionStore(writerPath, cwdOverride ?? header.cwd, { kind: "live", session, header }, writer.key);
350
+ const header = parseCurrentHeader(writerPath, firstLine);
351
+ const metadata = parseMikanMetadata(writerPath);
352
+ const opened = await openFileSession(writerPath, header);
353
+ return new SessionStore(writerPath, cwdOverride ?? header.cwd, { kind: "live", header, metadata, ...opened }, writer.key);
302
354
  }
303
355
  catch (error) {
304
356
  releaseWriter(writer.key);
305
357
  throw error;
306
358
  }
307
359
  }
308
- /** Open an immutable inspection handle that does not acquire a writer lease. */
309
360
  static async inspect(path) {
310
361
  const resolvedPath = canonicalSessionPath(path);
311
362
  const snapshotDir = mkdtempSync(join(tmpdir(), "mikan-session-inspect-"));
312
363
  const snapshotPath = join(snapshotDir, basename(resolvedPath));
364
+ let opened;
313
365
  try {
314
366
  writeFileSync(snapshotPath, readFileSync(resolvedPath), { mode: 0o600, flag: "wx" });
315
- const firstLine = readHeaderLine(snapshotPath);
316
- const header = parseV4Header(resolvedPath, firstLine);
317
- const session = await repo().open(metadataFromHeader(header, snapshotPath));
318
- return new ReadOnlySessionInspection(header, session);
367
+ const header = parseCurrentHeader(resolvedPath, readHeaderLine(snapshotPath));
368
+ const metadata = parseMikanMetadata(snapshotPath);
369
+ opened = await openFileSession(snapshotPath, header);
370
+ const entries = await opened.session.findEntries({ order: "asc" }, TODO_CONTEXT);
371
+ const name = await opened.session.getName(TODO_CONTEXT);
372
+ const branchObject = await mainBranch(opened.session, false);
373
+ const branch = branchObject
374
+ ? await branchObject.findEntries({ order: "oldestFirst" }, TODO_CONTEXT)
375
+ : [];
376
+ const context = buildContext(branch);
377
+ return new CachedSessionInspection(headerView(header, metadata), entries, name, branch, context);
319
378
  }
320
379
  finally {
380
+ if (opened) {
381
+ await opened.session.close(TODO_CONTEXT);
382
+ await opened.repo.close(TODO_CONTEXT);
383
+ }
321
384
  rmSync(snapshotDir, { recursive: true, force: true });
322
385
  }
323
386
  }
324
- /** Create a new session file exclusively; existing content is never replaced. */
325
387
  static async create(path, cwd, options) {
326
388
  const writer = acquireWriter(path);
327
389
  const writerPath = writer.path;
328
390
  let leaseKey = writer.key;
391
+ let opened;
329
392
  try {
330
- const header = buildHeader(cwd, options);
331
- writeFileSync(writerPath, `${JSON.stringify(header)}\n`, { mode: 0o600, flag: "wx" });
393
+ const built = buildHeader(cwd, options);
394
+ mkdirSync(dirname(writerPath), { recursive: true });
395
+ writeFileSync(writerPath, `${JSON.stringify(built.header)}\n`, { mode: 0o600, flag: "wx" });
332
396
  leaseKey = promotePendingWriter(leaseKey, writerPath);
333
- const session = await repo().open(metadataFromHeader(header, writerPath));
334
- return new SessionStore(writerPath, cwd, { kind: "live", session, header }, leaseKey);
397
+ opened = await openFileSession(writerPath, built.header);
398
+ if (built.metadata) {
399
+ await opened.session.setValue(MIKAN_METADATA, built.metadata, TODO_CONTEXT);
400
+ }
401
+ return new SessionStore(writerPath, cwd, { kind: "live", ...built, ...opened }, leaseKey);
335
402
  }
336
403
  catch (error) {
404
+ if (opened) {
405
+ await opened.session.close(TODO_CONTEXT).catch(() => undefined);
406
+ await opened.repo.close(TODO_CONTEXT).catch(() => undefined);
407
+ }
337
408
  releaseWriter(leaseKey);
338
409
  throw error;
339
410
  }
340
411
  }
341
- /**
342
- * Synchronously read a session file's header without opening the session.
343
- * Returns null for missing, empty, or unreadable files; throws only for
344
- * v3 files so callers surface the migration requirement.
345
- */
346
412
  static readHeader(path) {
347
413
  let firstLine;
348
414
  try {
@@ -354,31 +420,24 @@ export class SessionStore {
354
420
  if (firstLine.trim().length === 0)
355
421
  return null;
356
422
  try {
357
- return headerView(parseV4Header(path, firstLine));
423
+ const header = parseCurrentHeader(path, firstLine);
424
+ return headerView(header, parseMikanMetadata(path));
358
425
  }
359
426
  catch (error) {
360
- if (error instanceof SessionFormatError && /legacy v3/.test(error.message))
427
+ if (error instanceof SessionFormatError && /legacy v3|Pi 0\.84 v4/.test(error.message)) {
361
428
  throw error;
429
+ }
362
430
  return null;
363
431
  }
364
432
  }
365
- /**
366
- * Synchronously write a fresh header-only session file, replacing any
367
- * existing file. The session is opened later via {@link SessionStore.open}.
368
- */
369
433
  static writeHeaderFile(path, cwd, options) {
370
434
  mkdirSync(dirname(path), { recursive: true });
371
- atomicWritePrivateFile(path, `${JSON.stringify(buildHeader(cwd, options))}\n`);
435
+ const built = buildHeader(cwd, options);
436
+ atomicWritePrivateFile(path, `${JSON.stringify(built.header)}\n`);
372
437
  }
373
- /** Create an ephemeral session that never writes a session file. */
374
438
  static inMemory(cwd = process.cwd()) {
375
- const header = buildHeader(cwd);
376
- const storage = new InMemorySessionStorage({
377
- id: header.id,
378
- createdAt: header.createdAt,
379
- });
380
- const session = new Session(storage);
381
- return new SessionStore(null, cwd, { kind: "live", session, header }, null);
439
+ const built = buildHeader(cwd);
440
+ return new SessionStore(null, cwd, { kind: "pending", ...built, fingerprint: null }, null);
382
441
  }
383
442
  getSessionFile() {
384
443
  this.assertOpen();
@@ -392,12 +451,10 @@ export class SessionStore {
392
451
  this.assertOpen();
393
452
  return this.cwd;
394
453
  }
395
- /** Compatibility header view for callers that read mikan session lineage. */
396
454
  getHeader() {
397
455
  this.assertOpen();
398
- return headerView(this.state.header);
456
+ return headerView(this.state.header, this.state.metadata);
399
457
  }
400
- /** Stable session id: the header id, or a generated id for fresh sessions. */
401
458
  getSessionId() {
402
459
  this.assertOpen();
403
460
  return this.state.header.id;
@@ -406,85 +463,100 @@ export class SessionStore {
406
463
  this.assertOpen();
407
464
  if (this.state.kind === "pending")
408
465
  return null;
409
- return this.state.session.getLeafId();
466
+ return (await mainBranch(this.state.session, false))?.getTipId(TODO_CONTEXT) ?? null;
410
467
  }
411
468
  async getEntry(id) {
412
469
  this.assertOpen();
413
470
  if (this.state.kind === "pending")
414
471
  return undefined;
415
- return this.state.session.getEntry(id);
472
+ return this.state.session.getEntry(id, TODO_CONTEXT);
416
473
  }
417
- /** All session entries in append order. */
418
474
  async getEntries() {
419
475
  this.assertOpen();
420
476
  if (this.state.kind === "pending")
421
477
  return [];
422
- return this.state.session.findEntries({ order: "oldestFirst" });
478
+ return this.state.session.findEntries({ order: "asc" }, TODO_CONTEXT);
423
479
  }
424
- /** Latest user-assigned session name, if any. */
425
480
  async getSessionName() {
426
481
  this.assertOpen();
427
482
  if (this.state.kind === "pending")
428
483
  return undefined;
429
- return this.state.session.getName();
484
+ return this.state.session.getName(TODO_CONTEXT);
430
485
  }
431
- /**
432
- * Walk from an entry to the root, returning entries in path order
433
- * (root first). Defaults to the current leaf.
434
- */
435
486
  async getBranch(fromId) {
436
487
  this.assertOpen();
437
488
  if (this.state.kind === "pending")
438
489
  return [];
439
- return this.state.session.findEntriesOnBranch({
440
- order: "oldestFirst",
441
- ...(fromId !== undefined ? { start: fromId } : {}),
442
- });
490
+ if (fromId !== undefined) {
491
+ return this.state.session.scanBranch({ start: fromId, order: "oldestFirst" }, TODO_CONTEXT);
492
+ }
493
+ const branchObject = await mainBranch(this.state.session, false);
494
+ return branchObject ? branchObject.findEntries({ order: "oldestFirst" }, TODO_CONTEXT) : [];
443
495
  }
444
- /**
445
- * Build the LLM context (messages, thinking level, model) from the current
446
- * branch, resolving compaction and branch summaries along the path.
447
- */
448
496
  async buildSessionContext() {
449
- this.assertOpen();
450
- return buildContextFromEntries(await this.getBranch());
497
+ return buildContext(await this.getBranch());
451
498
  }
452
- /** Append a chat message as a child of the current leaf. Returns the entry id. */
453
499
  async appendMessage(message) {
454
- return this.mutate(async () => (await this.live()).appendMessage(toDurable(message)));
500
+ return this.mutate(async () => {
501
+ const branchObject = await mainBranch((await this.live()).session, true);
502
+ if (!branchObject)
503
+ throw new Error("Failed to create main session branch");
504
+ return branchObject.appendMessage(toDurable(message), TODO_CONTEXT);
505
+ });
455
506
  }
456
- /** Append a custom application data entry (never sent to the LLM). */
457
507
  async appendCustomEntry(customType, data) {
458
- return this.mutate(async () => (await this.live()).appendCustomEntry(customType, toDurable(data)));
508
+ return this.mutate(async () => {
509
+ const branchObject = await mainBranch((await this.live()).session, true);
510
+ if (!branchObject)
511
+ throw new Error("Failed to create main session branch");
512
+ return branchObject.appendCustomEntry(customType, toDurable(data), TODO_CONTEXT);
513
+ });
459
514
  }
460
- /** Append a custom message that participates in LLM context. */
461
515
  async appendCustomMessageEntry(customType, content, display, details) {
462
- return this.mutate(async () => (await this.live()).appendMessage(toDurable(createCustomMessage(customType, content, display, details, Date.now()))));
516
+ return this.appendMessage(toDurable(createCustomMessage(customType, content, display, details, Date.now())));
463
517
  }
464
- /** Record a user-visible session name. */
465
518
  async setSessionName(name) {
466
- await this.mutate(async () => (await this.live()).setName(name.trim() || undefined));
519
+ await this.mutate(async () => (await this.live()).session.setName(name.trim() || undefined, TODO_CONTEXT));
467
520
  }
468
- /** Persist a compaction summary produced for this session. */
469
521
  async appendCompaction(summary, retainedTail, tokensBefore, details) {
470
522
  return this.mutate(async () => {
471
- const session = await this.live();
472
- const entry = await session.appendEntry({
473
- type: "compaction",
474
- id: session.idGenerator.next(),
475
- summary,
476
- retainedTail: toDurable(retainedTail),
477
- tokensBefore,
478
- ...(details !== undefined ? { details: toDurable(details) } : {}),
479
- }, "main");
480
- return entry.id;
523
+ const { session } = await this.live();
524
+ const id = session.idGenerator.next();
525
+ await session.mutate(async (mutator, context) => {
526
+ const currentTip = await mutator.getValue(branchTip("main"), context);
527
+ await mutator.commit([
528
+ insertEntry({
529
+ type: "compaction",
530
+ id,
531
+ parentId: currentTip?.value ?? null,
532
+ summary,
533
+ retainedTail: toDurable(retainedTail),
534
+ tokensBefore,
535
+ fromHook: false,
536
+ ...(details !== undefined ? { details: toDurable(details) } : {}),
537
+ }),
538
+ setValue(branchTip("main"), id),
539
+ ], context);
540
+ }, TODO_CONTEXT);
541
+ return id;
481
542
  });
482
543
  }
483
544
  async close() {
484
545
  if (this.closePromise)
485
546
  return this.closePromise;
486
547
  this.closed = true;
487
- this.closePromise = this.mutationTail.finally(() => {
548
+ this.closePromise = this.mutationTail
549
+ .then(async () => {
550
+ if (this.state.kind !== "live")
551
+ return;
552
+ try {
553
+ await this.state.session.close(TODO_CONTEXT);
554
+ }
555
+ finally {
556
+ await this.state.repo?.close(TODO_CONTEXT);
557
+ }
558
+ })
559
+ .finally(() => {
488
560
  if (this.writerKey !== null)
489
561
  releaseWriter(this.writerKey);
490
562
  });
@@ -500,22 +572,45 @@ export class SessionStore {
500
572
  this.mutationTail = result.then(() => undefined, () => undefined);
501
573
  return result;
502
574
  }
503
- /** Materialize the session file (when persisted) and return the live session. */
504
575
  async live() {
505
576
  if (this.state.kind === "live")
506
- return this.state.session;
577
+ return this.state;
507
578
  const pending = this.state;
508
- const header = pending.header;
509
579
  const sessionFile = this.sessionFile;
510
- if (sessionFile === null)
511
- throw new Error("Pending session must have a session file");
512
- materializePendingFile(sessionFile, header, pending.fingerprint);
580
+ if (sessionFile === null) {
581
+ const repo = new MemorySessionRepo();
582
+ const session = await repo.create({
583
+ id: pending.header.id,
584
+ ...(pending.header.parentSessionId !== undefined
585
+ ? { parentSessionId: pending.header.parentSessionId }
586
+ : {}),
587
+ }, TODO_CONTEXT);
588
+ const live = {
589
+ kind: "live",
590
+ header: pending.header,
591
+ metadata: pending.metadata,
592
+ session,
593
+ repo,
594
+ };
595
+ this.state = live;
596
+ return live;
597
+ }
598
+ materializePendingFile(sessionFile, pending.header, pending.fingerprint);
513
599
  if (this.writerKey === null)
514
600
  throw new Error("Pending session must have a writer lease");
515
601
  this.writerKey = promotePendingWriter(this.writerKey, sessionFile);
516
- const session = await repo().open(metadataFromHeader(header, sessionFile));
517
- this.state = { kind: "live", session, header };
518
- return session;
602
+ const opened = await openFileSession(sessionFile, pending.header);
603
+ if (pending.metadata) {
604
+ await opened.session.setValue(MIKAN_METADATA, pending.metadata, TODO_CONTEXT);
605
+ }
606
+ const live = {
607
+ kind: "live",
608
+ header: pending.header,
609
+ metadata: pending.metadata,
610
+ ...opened,
611
+ };
612
+ this.state = live;
613
+ return live;
519
614
  }
520
615
  }
521
616
  //# sourceMappingURL=session-store.js.map