@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.
- package/CHANGELOG.md +17 -0
- package/dist/adapters/discord/context.d.ts.map +1 -1
- package/dist/adapters/discord/context.js +2 -6
- package/dist/adapters/discord/context.js.map +1 -1
- package/dist/adapters/telegram/context.d.ts.map +1 -1
- package/dist/adapters/telegram/context.js +2 -4
- package/dist/adapters/telegram/context.js.map +1 -1
- package/dist/agent/presenter.d.ts +3 -10
- package/dist/agent/presenter.d.ts.map +1 -1
- package/dist/agent/presenter.js +35 -32
- package/dist/agent/presenter.js.map +1 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +16 -22
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/types.d.ts +4 -6
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/cli/boot.js +1 -1
- package/dist/cli/boot.js.map +1 -1
- package/dist/cli/sessions.d.ts.map +1 -1
- package/dist/cli/sessions.js +18 -13
- package/dist/cli/sessions.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -2
- package/dist/config.js.map +1 -1
- package/dist/env-manifest.js +2 -2
- package/dist/env-manifest.js.map +1 -1
- package/dist/harness/models.d.ts.map +1 -1
- package/dist/harness/models.js +1 -4
- package/dist/harness/models.js.map +1 -1
- package/dist/harness/runner.d.ts.map +1 -1
- package/dist/harness/runner.js +2 -2
- package/dist/harness/runner.js.map +1 -1
- package/dist/harness/session-store.d.ts +3 -45
- package/dist/harness/session-store.d.ts.map +1 -1
- package/dist/harness/session-store.js +293 -198
- package/dist/harness/session-store.js.map +1 -1
- package/dist/harness/subagent-runner.d.ts.map +1 -1
- package/dist/harness/subagent-runner.js +1 -8
- package/dist/harness/subagent-runner.js.map +1 -1
- package/dist/harness/types.d.ts +11 -11
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/main.js +5 -0
- package/dist/main.js.map +1 -1
- package/dist/mcp/catalog.d.ts.map +1 -1
- package/dist/mcp/catalog.js +0 -88
- package/dist/mcp/catalog.js.map +1 -1
- package/dist/mcp/open-connector.d.ts +1 -1
- package/dist/mcp/open-connector.d.ts.map +1 -1
- package/dist/mcp/open-connector.js +23 -22
- package/dist/mcp/open-connector.js.map +1 -1
- package/dist/runtime/conversation-runtime.js +1 -0
- package/dist/runtime/conversation-runtime.js.map +1 -1
- package/dist/runtime/session-lifecycle.d.ts +0 -1
- package/dist/runtime/session-lifecycle.d.ts.map +1 -1
- package/dist/runtime/session-lifecycle.js +1 -4
- package/dist/runtime/session-lifecycle.js.map +1 -1
- package/dist/runtime/types.d.ts +3 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js.map +1 -1
- package/dist/sessions/migrate-pi-084.d.ts +8 -0
- package/dist/sessions/migrate-pi-084.d.ts.map +1 -0
- package/dist/sessions/migrate-pi-084.js +353 -0
- package/dist/sessions/migrate-pi-084.js.map +1 -0
- package/dist/sessions/migrate-v3.d.ts +0 -5
- package/dist/sessions/migrate-v3.d.ts.map +1 -1
- package/dist/sessions/migrate-v3.js +129 -51
- package/dist/sessions/migrate-v3.js.map +1 -1
- package/dist/sessions/types.d.ts +6 -1
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/web/session-view/portal.js +0 -16
- package/dist/web/session-view/portal.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mikan-owned
|
|
2
|
+
* mikan-owned facade over Pi's current JSONL v4 SessionRepo.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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
|
|
22
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
170
|
-
return `${
|
|
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
|
|
191
|
-
const bytes = Buffer.alloc(
|
|
196
|
+
const stats = fstatSync(fd);
|
|
197
|
+
const bytes = Buffer.alloc(stats.size);
|
|
192
198
|
readSync(fd, bytes, 0, bytes.length, 0);
|
|
193
|
-
const actual = `${
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
238
|
-
|
|
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.
|
|
296
|
+
this.entries = entries;
|
|
297
|
+
this.name = name;
|
|
298
|
+
this.branch = branch;
|
|
299
|
+
this.context = context;
|
|
241
300
|
}
|
|
242
301
|
getHeader() {
|
|
243
|
-
return
|
|
302
|
+
return structuredClone(this.header);
|
|
244
303
|
}
|
|
245
304
|
async getEntries() {
|
|
246
|
-
return this.
|
|
305
|
+
return structuredClone(this.entries);
|
|
247
306
|
}
|
|
248
307
|
async getSessionName() {
|
|
249
|
-
return this.
|
|
308
|
+
return this.name;
|
|
250
309
|
}
|
|
251
310
|
async getBranch(fromId) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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 =
|
|
300
|
-
const
|
|
301
|
-
|
|
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
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
376
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
440
|
-
order: "oldestFirst",
|
|
441
|
-
|
|
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.
|
|
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 () =>
|
|
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 () =>
|
|
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.
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
512
|
-
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
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
|