@geminixiang/mikan 1.0.0-beta.46 → 1.0.0-beta.48
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 +32 -0
- package/dist/adapters/discord/bot.d.ts +3 -0
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +27 -5
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/github/bot.d.ts +6 -1
- package/dist/adapters/github/bot.d.ts.map +1 -1
- package/dist/adapters/github/bot.js +56 -21
- package/dist/adapters/github/bot.js.map +1 -1
- package/dist/adapters/shared.d.ts +12 -1
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/adapters/shared.js +44 -1
- package/dist/adapters/shared.js.map +1 -1
- package/dist/adapters/slack/bot.d.ts +3 -0
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +75 -28
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/telegram/bot.d.ts +3 -0
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +23 -7
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/agent/catalog.d.ts.map +1 -1
- package/dist/agent/catalog.js +5 -1
- package/dist/agent/catalog.js.map +1 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +116 -46
- package/dist/agent/runner.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/env-manifest.d.ts.map +1 -1
- package/dist/env-manifest.js +17 -0
- package/dist/env-manifest.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 +296 -198
- package/dist/harness/session-store.js.map +1 -1
- package/dist/harness/types.d.ts +9 -5
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/main.js +74 -12
- package/dist/main.js.map +1 -1
- package/dist/mcp/catalog.d.ts.map +1 -1
- package/dist/mcp/catalog.js +21 -0
- package/dist/mcp/catalog.js.map +1 -1
- package/dist/mcp/loader.d.ts +3 -2
- package/dist/mcp/loader.d.ts.map +1 -1
- package/dist/mcp/loader.js +54 -20
- package/dist/mcp/loader.js.map +1 -1
- package/dist/mcp/open-connector.d.ts +14 -0
- package/dist/mcp/open-connector.d.ts.map +1 -0
- package/dist/mcp/open-connector.js +203 -0
- package/dist/mcp/open-connector.js.map +1 -0
- package/dist/mcp/types.d.ts +6 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/mcp/types.js.map +1 -1
- package/dist/packages/index.d.ts.map +1 -1
- package/dist/packages/index.js +20 -49
- package/dist/packages/index.js.map +1 -1
- package/dist/process-lifecycle.d.ts +19 -0
- package/dist/process-lifecycle.d.ts.map +1 -0
- package/dist/process-lifecycle.js +60 -0
- package/dist/process-lifecycle.js.map +1 -0
- package/dist/runtime/conversation-runtime.js +11 -4
- package/dist/runtime/conversation-runtime.js.map +1 -1
- package/dist/runtime/session-lifecycle.d.ts +3 -1
- package/dist/runtime/session-lifecycle.d.ts.map +1 -1
- package/dist/runtime/session-lifecycle.js +22 -2
- package/dist/runtime/session-lifecycle.js.map +1 -1
- package/dist/runtime/types.d.ts +5 -1
- 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 +16 -3
- 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,178 @@ 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
|
+
function fileRepo(path) {
|
|
248
|
+
return new JsonlSessionRepo({
|
|
249
|
+
fileSystem: new NodeExecutionEnv({ cwd: process.cwd() }),
|
|
250
|
+
sessionsRoot: dirname(path),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function openFileSession(path, header) {
|
|
254
|
+
const sessionRepo = fileRepo(path);
|
|
255
|
+
try {
|
|
256
|
+
const session = await sessionRepo.open(metadataFromHeader(header, path), TODO_CONTEXT);
|
|
257
|
+
return { session, repo: sessionRepo };
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
await sessionRepo.close(TODO_CONTEXT);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function mainBranch(session, create) {
|
|
265
|
+
const existing = await session.branch("main", TODO_CONTEXT);
|
|
266
|
+
if (existing || !create)
|
|
267
|
+
return existing;
|
|
268
|
+
return session.createBranch("main", null, TODO_CONTEXT);
|
|
269
|
+
}
|
|
270
|
+
function isContextMessage(message) {
|
|
271
|
+
return (message.role !== "assistant" ||
|
|
272
|
+
(message.stopReason !== "error" &&
|
|
273
|
+
message.stopReason !== "aborted" &&
|
|
274
|
+
message.stopReason !== "deferred"));
|
|
275
|
+
}
|
|
276
|
+
function buildContext(entries) {
|
|
277
|
+
const compactionIndex = entries.findLastIndex((entry) => entry.type === "compaction");
|
|
278
|
+
const visibleEntries = compactionIndex === -1
|
|
279
|
+
? entries
|
|
280
|
+
: [entries[compactionIndex], ...entries.slice(compactionIndex + 1)];
|
|
281
|
+
const messages = [];
|
|
282
|
+
for (const entry of visibleEntries) {
|
|
283
|
+
if (entry.type === "message") {
|
|
284
|
+
if (isContextMessage(entry.message))
|
|
285
|
+
messages.push(entry.message);
|
|
286
|
+
}
|
|
287
|
+
else if (entry.type === "compaction") {
|
|
288
|
+
messages.push(createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), ...entry.retainedTail.filter(isContextMessage));
|
|
289
|
+
}
|
|
290
|
+
else if (entry.type === "branch_summary" && entry.summary) {
|
|
291
|
+
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { messages };
|
|
295
|
+
}
|
|
296
|
+
class CachedSessionInspection {
|
|
297
|
+
constructor(header, entries, name, branch, context) {
|
|
239
298
|
this.header = header;
|
|
240
|
-
this.
|
|
299
|
+
this.entries = entries;
|
|
300
|
+
this.name = name;
|
|
301
|
+
this.branch = branch;
|
|
302
|
+
this.context = context;
|
|
241
303
|
}
|
|
242
304
|
getHeader() {
|
|
243
|
-
return
|
|
305
|
+
return structuredClone(this.header);
|
|
244
306
|
}
|
|
245
307
|
async getEntries() {
|
|
246
|
-
return this.
|
|
308
|
+
return structuredClone(this.entries);
|
|
247
309
|
}
|
|
248
310
|
async getSessionName() {
|
|
249
|
-
return this.
|
|
311
|
+
return this.name;
|
|
250
312
|
}
|
|
251
313
|
async getBranch(fromId) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
314
|
+
if (fromId === undefined)
|
|
315
|
+
return structuredClone(this.branch);
|
|
316
|
+
const byId = new Map(this.entries.map((entry) => [entry.id, entry]));
|
|
317
|
+
const result = [];
|
|
318
|
+
let current = byId.get(fromId);
|
|
319
|
+
while (current) {
|
|
320
|
+
result.push(current);
|
|
321
|
+
current = current.parentId === null ? undefined : byId.get(current.parentId);
|
|
322
|
+
}
|
|
323
|
+
return result.toReversed();
|
|
256
324
|
}
|
|
257
325
|
async buildSessionContext() {
|
|
258
|
-
return
|
|
326
|
+
return structuredClone(this.context);
|
|
259
327
|
}
|
|
260
328
|
}
|
|
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
329
|
export class SessionStore {
|
|
269
330
|
constructor(sessionFile, cwd, state, writerKey) {
|
|
270
|
-
this.mutationTail = Promise.resolve();
|
|
271
|
-
this.closed = false;
|
|
272
331
|
this.sessionFile = sessionFile;
|
|
273
332
|
this.cwd = cwd;
|
|
274
333
|
this.state = state;
|
|
275
334
|
this.writerKey = writerKey;
|
|
335
|
+
this.mutationTail = Promise.resolve();
|
|
336
|
+
this.closed = false;
|
|
276
337
|
}
|
|
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
338
|
static async open(path, cwdOverride) {
|
|
283
339
|
const writer = acquireWriter(path);
|
|
284
340
|
const writerPath = writer.path;
|
|
285
341
|
try {
|
|
286
342
|
if (!existsSync(writerPath)) {
|
|
287
343
|
const cwd = cwdOverride ?? process.cwd();
|
|
288
|
-
|
|
344
|
+
const built = buildHeader(cwd);
|
|
345
|
+
return new SessionStore(writerPath, cwd, { kind: "pending", ...built, fingerprint: null }, writer.key);
|
|
289
346
|
}
|
|
290
347
|
const firstLine = readHeaderLine(writerPath);
|
|
291
348
|
if (firstLine.trim().length === 0) {
|
|
292
349
|
const cwd = cwdOverride ?? process.cwd();
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
header: buildHeader(cwd),
|
|
296
|
-
fingerprint: fileFingerprint(writerPath),
|
|
297
|
-
}, writer.key);
|
|
350
|
+
const built = buildHeader(cwd);
|
|
351
|
+
return new SessionStore(writerPath, cwd, { kind: "pending", ...built, fingerprint: fileFingerprint(writerPath) }, writer.key);
|
|
298
352
|
}
|
|
299
|
-
const header =
|
|
300
|
-
const
|
|
301
|
-
|
|
353
|
+
const header = parseCurrentHeader(writerPath, firstLine);
|
|
354
|
+
const metadata = parseMikanMetadata(writerPath);
|
|
355
|
+
const opened = await openFileSession(writerPath, header);
|
|
356
|
+
return new SessionStore(writerPath, cwdOverride ?? header.cwd, { kind: "live", header, metadata, ...opened }, writer.key);
|
|
302
357
|
}
|
|
303
358
|
catch (error) {
|
|
304
359
|
releaseWriter(writer.key);
|
|
305
360
|
throw error;
|
|
306
361
|
}
|
|
307
362
|
}
|
|
308
|
-
/** Open an immutable inspection handle that does not acquire a writer lease. */
|
|
309
363
|
static async inspect(path) {
|
|
310
364
|
const resolvedPath = canonicalSessionPath(path);
|
|
311
365
|
const snapshotDir = mkdtempSync(join(tmpdir(), "mikan-session-inspect-"));
|
|
312
366
|
const snapshotPath = join(snapshotDir, basename(resolvedPath));
|
|
367
|
+
let opened;
|
|
313
368
|
try {
|
|
314
369
|
writeFileSync(snapshotPath, readFileSync(resolvedPath), { mode: 0o600, flag: "wx" });
|
|
315
|
-
const
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
370
|
+
const header = parseCurrentHeader(resolvedPath, readHeaderLine(snapshotPath));
|
|
371
|
+
const metadata = parseMikanMetadata(snapshotPath);
|
|
372
|
+
opened = await openFileSession(snapshotPath, header);
|
|
373
|
+
const entries = await opened.session.findEntries({ order: "asc" }, TODO_CONTEXT);
|
|
374
|
+
const name = await opened.session.getName(TODO_CONTEXT);
|
|
375
|
+
const branchObject = await mainBranch(opened.session, false);
|
|
376
|
+
const branch = branchObject
|
|
377
|
+
? await branchObject.findEntries({ order: "oldestFirst" }, TODO_CONTEXT)
|
|
378
|
+
: [];
|
|
379
|
+
const context = buildContext(branch);
|
|
380
|
+
return new CachedSessionInspection(headerView(header, metadata), entries, name, branch, context);
|
|
319
381
|
}
|
|
320
382
|
finally {
|
|
383
|
+
if (opened) {
|
|
384
|
+
await opened.session.close(TODO_CONTEXT);
|
|
385
|
+
await opened.repo.close(TODO_CONTEXT);
|
|
386
|
+
}
|
|
321
387
|
rmSync(snapshotDir, { recursive: true, force: true });
|
|
322
388
|
}
|
|
323
389
|
}
|
|
324
|
-
/** Create a new session file exclusively; existing content is never replaced. */
|
|
325
390
|
static async create(path, cwd, options) {
|
|
326
391
|
const writer = acquireWriter(path);
|
|
327
392
|
const writerPath = writer.path;
|
|
328
393
|
let leaseKey = writer.key;
|
|
394
|
+
let opened;
|
|
329
395
|
try {
|
|
330
|
-
const
|
|
331
|
-
|
|
396
|
+
const built = buildHeader(cwd, options);
|
|
397
|
+
mkdirSync(dirname(writerPath), { recursive: true });
|
|
398
|
+
writeFileSync(writerPath, `${JSON.stringify(built.header)}\n`, { mode: 0o600, flag: "wx" });
|
|
332
399
|
leaseKey = promotePendingWriter(leaseKey, writerPath);
|
|
333
|
-
|
|
334
|
-
|
|
400
|
+
opened = await openFileSession(writerPath, built.header);
|
|
401
|
+
if (built.metadata) {
|
|
402
|
+
await opened.session.setValue(MIKAN_METADATA, built.metadata, TODO_CONTEXT);
|
|
403
|
+
}
|
|
404
|
+
return new SessionStore(writerPath, cwd, { kind: "live", ...built, ...opened }, leaseKey);
|
|
335
405
|
}
|
|
336
406
|
catch (error) {
|
|
407
|
+
if (opened) {
|
|
408
|
+
await opened.session.close(TODO_CONTEXT).catch(() => undefined);
|
|
409
|
+
await opened.repo.close(TODO_CONTEXT).catch(() => undefined);
|
|
410
|
+
}
|
|
337
411
|
releaseWriter(leaseKey);
|
|
338
412
|
throw error;
|
|
339
413
|
}
|
|
340
414
|
}
|
|
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
415
|
static readHeader(path) {
|
|
347
416
|
let firstLine;
|
|
348
417
|
try {
|
|
@@ -354,31 +423,24 @@ export class SessionStore {
|
|
|
354
423
|
if (firstLine.trim().length === 0)
|
|
355
424
|
return null;
|
|
356
425
|
try {
|
|
357
|
-
|
|
426
|
+
const header = parseCurrentHeader(path, firstLine);
|
|
427
|
+
return headerView(header, parseMikanMetadata(path));
|
|
358
428
|
}
|
|
359
429
|
catch (error) {
|
|
360
|
-
if (error instanceof SessionFormatError && /legacy v3/.test(error.message))
|
|
430
|
+
if (error instanceof SessionFormatError && /legacy v3|Pi 0\.84 v4/.test(error.message)) {
|
|
361
431
|
throw error;
|
|
432
|
+
}
|
|
362
433
|
return null;
|
|
363
434
|
}
|
|
364
435
|
}
|
|
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
436
|
static writeHeaderFile(path, cwd, options) {
|
|
370
437
|
mkdirSync(dirname(path), { recursive: true });
|
|
371
|
-
|
|
438
|
+
const built = buildHeader(cwd, options);
|
|
439
|
+
atomicWritePrivateFile(path, `${JSON.stringify(built.header)}\n`);
|
|
372
440
|
}
|
|
373
|
-
/** Create an ephemeral session that never writes a session file. */
|
|
374
441
|
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);
|
|
442
|
+
const built = buildHeader(cwd);
|
|
443
|
+
return new SessionStore(null, cwd, { kind: "pending", ...built, fingerprint: null }, null);
|
|
382
444
|
}
|
|
383
445
|
getSessionFile() {
|
|
384
446
|
this.assertOpen();
|
|
@@ -392,12 +454,10 @@ export class SessionStore {
|
|
|
392
454
|
this.assertOpen();
|
|
393
455
|
return this.cwd;
|
|
394
456
|
}
|
|
395
|
-
/** Compatibility header view for callers that read mikan session lineage. */
|
|
396
457
|
getHeader() {
|
|
397
458
|
this.assertOpen();
|
|
398
|
-
return headerView(this.state.header);
|
|
459
|
+
return headerView(this.state.header, this.state.metadata);
|
|
399
460
|
}
|
|
400
|
-
/** Stable session id: the header id, or a generated id for fresh sessions. */
|
|
401
461
|
getSessionId() {
|
|
402
462
|
this.assertOpen();
|
|
403
463
|
return this.state.header.id;
|
|
@@ -406,85 +466,100 @@ export class SessionStore {
|
|
|
406
466
|
this.assertOpen();
|
|
407
467
|
if (this.state.kind === "pending")
|
|
408
468
|
return null;
|
|
409
|
-
return this.state.session
|
|
469
|
+
return (await mainBranch(this.state.session, false))?.getTipId(TODO_CONTEXT) ?? null;
|
|
410
470
|
}
|
|
411
471
|
async getEntry(id) {
|
|
412
472
|
this.assertOpen();
|
|
413
473
|
if (this.state.kind === "pending")
|
|
414
474
|
return undefined;
|
|
415
|
-
return this.state.session.getEntry(id);
|
|
475
|
+
return this.state.session.getEntry(id, TODO_CONTEXT);
|
|
416
476
|
}
|
|
417
|
-
/** All session entries in append order. */
|
|
418
477
|
async getEntries() {
|
|
419
478
|
this.assertOpen();
|
|
420
479
|
if (this.state.kind === "pending")
|
|
421
480
|
return [];
|
|
422
|
-
return this.state.session.findEntries({ order: "
|
|
481
|
+
return this.state.session.findEntries({ order: "asc" }, TODO_CONTEXT);
|
|
423
482
|
}
|
|
424
|
-
/** Latest user-assigned session name, if any. */
|
|
425
483
|
async getSessionName() {
|
|
426
484
|
this.assertOpen();
|
|
427
485
|
if (this.state.kind === "pending")
|
|
428
486
|
return undefined;
|
|
429
|
-
return this.state.session.getName();
|
|
487
|
+
return this.state.session.getName(TODO_CONTEXT);
|
|
430
488
|
}
|
|
431
|
-
/**
|
|
432
|
-
* Walk from an entry to the root, returning entries in path order
|
|
433
|
-
* (root first). Defaults to the current leaf.
|
|
434
|
-
*/
|
|
435
489
|
async getBranch(fromId) {
|
|
436
490
|
this.assertOpen();
|
|
437
491
|
if (this.state.kind === "pending")
|
|
438
492
|
return [];
|
|
439
|
-
|
|
440
|
-
order: "oldestFirst",
|
|
441
|
-
|
|
442
|
-
|
|
493
|
+
if (fromId !== undefined) {
|
|
494
|
+
return this.state.session.scanBranch({ start: fromId, order: "oldestFirst" }, TODO_CONTEXT);
|
|
495
|
+
}
|
|
496
|
+
const branchObject = await mainBranch(this.state.session, false);
|
|
497
|
+
return branchObject ? branchObject.findEntries({ order: "oldestFirst" }, TODO_CONTEXT) : [];
|
|
443
498
|
}
|
|
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
499
|
async buildSessionContext() {
|
|
449
|
-
this.
|
|
450
|
-
return buildContextFromEntries(await this.getBranch());
|
|
500
|
+
return buildContext(await this.getBranch());
|
|
451
501
|
}
|
|
452
|
-
/** Append a chat message as a child of the current leaf. Returns the entry id. */
|
|
453
502
|
async appendMessage(message) {
|
|
454
|
-
return this.mutate(async () =>
|
|
503
|
+
return this.mutate(async () => {
|
|
504
|
+
const branchObject = await mainBranch((await this.live()).session, true);
|
|
505
|
+
if (!branchObject)
|
|
506
|
+
throw new Error("Failed to create main session branch");
|
|
507
|
+
return branchObject.appendMessage(toDurable(message), TODO_CONTEXT);
|
|
508
|
+
});
|
|
455
509
|
}
|
|
456
|
-
/** Append a custom application data entry (never sent to the LLM). */
|
|
457
510
|
async appendCustomEntry(customType, data) {
|
|
458
|
-
return this.mutate(async () =>
|
|
511
|
+
return this.mutate(async () => {
|
|
512
|
+
const branchObject = await mainBranch((await this.live()).session, true);
|
|
513
|
+
if (!branchObject)
|
|
514
|
+
throw new Error("Failed to create main session branch");
|
|
515
|
+
return branchObject.appendCustomEntry(customType, toDurable(data), TODO_CONTEXT);
|
|
516
|
+
});
|
|
459
517
|
}
|
|
460
|
-
/** Append a custom message that participates in LLM context. */
|
|
461
518
|
async appendCustomMessageEntry(customType, content, display, details) {
|
|
462
|
-
return this.
|
|
519
|
+
return this.appendMessage(toDurable(createCustomMessage(customType, content, display, details, Date.now())));
|
|
463
520
|
}
|
|
464
|
-
/** Record a user-visible session name. */
|
|
465
521
|
async setSessionName(name) {
|
|
466
|
-
await this.mutate(async () => (await this.live()).setName(name.trim() || undefined));
|
|
522
|
+
await this.mutate(async () => (await this.live()).session.setName(name.trim() || undefined, TODO_CONTEXT));
|
|
467
523
|
}
|
|
468
|
-
/** Persist a compaction summary produced for this session. */
|
|
469
524
|
async appendCompaction(summary, retainedTail, tokensBefore, details) {
|
|
470
525
|
return this.mutate(async () => {
|
|
471
|
-
const session = await this.live();
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
526
|
+
const { session } = await this.live();
|
|
527
|
+
const id = session.idGenerator.next();
|
|
528
|
+
await session.mutate(async (mutator, context) => {
|
|
529
|
+
const currentTip = await mutator.getValue(branchTip("main"), context);
|
|
530
|
+
await mutator.commit([
|
|
531
|
+
insertEntry({
|
|
532
|
+
type: "compaction",
|
|
533
|
+
id,
|
|
534
|
+
parentId: currentTip?.value ?? null,
|
|
535
|
+
summary,
|
|
536
|
+
retainedTail: toDurable(retainedTail),
|
|
537
|
+
tokensBefore,
|
|
538
|
+
fromHook: false,
|
|
539
|
+
...(details !== undefined ? { details: toDurable(details) } : {}),
|
|
540
|
+
}),
|
|
541
|
+
setValue(branchTip("main"), id),
|
|
542
|
+
], context);
|
|
543
|
+
}, TODO_CONTEXT);
|
|
544
|
+
return id;
|
|
481
545
|
});
|
|
482
546
|
}
|
|
483
547
|
async close() {
|
|
484
548
|
if (this.closePromise)
|
|
485
549
|
return this.closePromise;
|
|
486
550
|
this.closed = true;
|
|
487
|
-
this.closePromise = this.mutationTail
|
|
551
|
+
this.closePromise = this.mutationTail
|
|
552
|
+
.then(async () => {
|
|
553
|
+
if (this.state.kind !== "live")
|
|
554
|
+
return;
|
|
555
|
+
try {
|
|
556
|
+
await this.state.session.close(TODO_CONTEXT);
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
await this.state.repo?.close(TODO_CONTEXT);
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
.finally(() => {
|
|
488
563
|
if (this.writerKey !== null)
|
|
489
564
|
releaseWriter(this.writerKey);
|
|
490
565
|
});
|
|
@@ -500,22 +575,45 @@ export class SessionStore {
|
|
|
500
575
|
this.mutationTail = result.then(() => undefined, () => undefined);
|
|
501
576
|
return result;
|
|
502
577
|
}
|
|
503
|
-
/** Materialize the session file (when persisted) and return the live session. */
|
|
504
578
|
async live() {
|
|
505
579
|
if (this.state.kind === "live")
|
|
506
|
-
return this.state
|
|
580
|
+
return this.state;
|
|
507
581
|
const pending = this.state;
|
|
508
|
-
const header = pending.header;
|
|
509
582
|
const sessionFile = this.sessionFile;
|
|
510
|
-
if (sessionFile === null)
|
|
511
|
-
|
|
512
|
-
|
|
583
|
+
if (sessionFile === null) {
|
|
584
|
+
const repo = new MemorySessionRepo();
|
|
585
|
+
const session = await repo.create({
|
|
586
|
+
id: pending.header.id,
|
|
587
|
+
...(pending.header.parentSessionId !== undefined
|
|
588
|
+
? { parentSessionId: pending.header.parentSessionId }
|
|
589
|
+
: {}),
|
|
590
|
+
}, TODO_CONTEXT);
|
|
591
|
+
const live = {
|
|
592
|
+
kind: "live",
|
|
593
|
+
header: pending.header,
|
|
594
|
+
metadata: pending.metadata,
|
|
595
|
+
session,
|
|
596
|
+
repo,
|
|
597
|
+
};
|
|
598
|
+
this.state = live;
|
|
599
|
+
return live;
|
|
600
|
+
}
|
|
601
|
+
materializePendingFile(sessionFile, pending.header, pending.fingerprint);
|
|
513
602
|
if (this.writerKey === null)
|
|
514
603
|
throw new Error("Pending session must have a writer lease");
|
|
515
604
|
this.writerKey = promotePendingWriter(this.writerKey, sessionFile);
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
605
|
+
const opened = await openFileSession(sessionFile, pending.header);
|
|
606
|
+
if (pending.metadata) {
|
|
607
|
+
await opened.session.setValue(MIKAN_METADATA, pending.metadata, TODO_CONTEXT);
|
|
608
|
+
}
|
|
609
|
+
const live = {
|
|
610
|
+
kind: "live",
|
|
611
|
+
header: pending.header,
|
|
612
|
+
metadata: pending.metadata,
|
|
613
|
+
...opened,
|
|
614
|
+
};
|
|
615
|
+
this.state = live;
|
|
616
|
+
return live;
|
|
519
617
|
}
|
|
520
618
|
}
|
|
521
619
|
//# sourceMappingURL=session-store.js.map
|