@mariozechner/pi-coding-agent 0.30.2 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +244 -1
- package/README.md +105 -84
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +5 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/file-processor.d.ts +3 -3
- package/dist/cli/file-processor.d.ts.map +1 -1
- package/dist/cli/file-processor.js +7 -10
- package/dist/cli/file-processor.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +73 -34
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +464 -210
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/auth-storage.d.ts +2 -2
- package/dist/core/auth-storage.d.ts.map +1 -1
- package/dist/core/auth-storage.js +2 -2
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/bash-executor.d.ts +2 -2
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +2 -2
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/compaction/branch-summarization.d.ts +84 -0
- package/dist/core/compaction/branch-summarization.d.ts.map +1 -0
- package/dist/core/compaction/branch-summarization.js +233 -0
- package/dist/core/compaction/branch-summarization.js.map +1 -0
- package/dist/core/{compaction.d.ts → compaction/compaction.d.ts} +38 -19
- package/dist/core/compaction/compaction.d.ts.map +1 -0
- package/dist/core/compaction/compaction.js +558 -0
- package/dist/core/compaction/compaction.js.map +1 -0
- package/dist/core/compaction/index.d.ts +7 -0
- package/dist/core/compaction/index.d.ts.map +1 -0
- package/dist/core/compaction/index.js +7 -0
- package/dist/core/compaction/index.js.map +1 -0
- package/dist/core/compaction/utils.d.ts +35 -0
- package/dist/core/compaction/utils.d.ts.map +1 -0
- package/dist/core/compaction/utils.js +138 -0
- package/dist/core/compaction/utils.js.map +1 -0
- package/dist/core/custom-tools/index.d.ts +2 -1
- package/dist/core/custom-tools/index.d.ts.map +1 -1
- package/dist/core/custom-tools/index.js +1 -0
- package/dist/core/custom-tools/index.js.map +1 -1
- package/dist/core/custom-tools/loader.d.ts.map +1 -1
- package/dist/core/custom-tools/loader.js +13 -80
- package/dist/core/custom-tools/loader.js.map +1 -1
- package/dist/core/custom-tools/types.d.ts +84 -59
- package/dist/core/custom-tools/types.d.ts.map +1 -1
- package/dist/core/custom-tools/types.js.map +1 -1
- package/dist/core/custom-tools/wrapper.d.ts +15 -0
- package/dist/core/custom-tools/wrapper.d.ts.map +1 -0
- package/dist/core/custom-tools/wrapper.js +23 -0
- package/dist/core/custom-tools/wrapper.js.map +1 -0
- package/dist/core/exec.d.ts +29 -0
- package/dist/core/exec.d.ts.map +1 -0
- package/dist/core/exec.js +71 -0
- package/dist/core/exec.js.map +1 -0
- package/dist/core/export-html/index.d.ts +17 -0
- package/dist/core/export-html/index.d.ts.map +1 -0
- package/dist/core/export-html/index.js +171 -0
- package/dist/core/export-html/index.js.map +1 -0
- package/dist/core/export-html/template.css +781 -0
- package/dist/core/export-html/template.html +54 -0
- package/dist/core/export-html/template.js +1185 -0
- package/dist/core/export-html/vendor/highlight.min.js +1213 -0
- package/dist/core/export-html/vendor/marked.min.js +6 -0
- package/dist/core/hooks/index.d.ts +4 -4
- package/dist/core/hooks/index.d.ts.map +1 -1
- package/dist/core/hooks/index.js +3 -3
- package/dist/core/hooks/index.js.map +1 -1
- package/dist/core/hooks/loader.d.ts +40 -5
- package/dist/core/hooks/loader.d.ts.map +1 -1
- package/dist/core/hooks/loader.js +43 -10
- package/dist/core/hooks/loader.js.map +1 -1
- package/dist/core/hooks/runner.d.ts +94 -18
- package/dist/core/hooks/runner.d.ts.map +1 -1
- package/dist/core/hooks/runner.js +199 -120
- package/dist/core/hooks/runner.js.map +1 -1
- package/dist/core/hooks/tool-wrapper.d.ts +1 -1
- package/dist/core/hooks/tool-wrapper.d.ts.map +1 -1
- package/dist/core/hooks/tool-wrapper.js +36 -19
- package/dist/core/hooks/tool-wrapper.js.map +1 -1
- package/dist/core/hooks/types.d.ts +407 -96
- package/dist/core/hooks/types.d.ts.map +1 -1
- package/dist/core/hooks/types.js.map +1 -1
- package/dist/core/index.d.ts +4 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/messages.d.ts +44 -12
- package/dist/core/messages.d.ts.map +1 -1
- package/dist/core/messages.js +82 -34
- package/dist/core/messages.js.map +1 -1
- package/dist/core/model-registry.d.ts +5 -5
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +7 -7
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-resolver.d.ts +7 -7
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +45 -14
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/sdk.d.ts +7 -10
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +88 -32
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +202 -36
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +565 -133
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +9 -3
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +13 -12
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +6 -3
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/bash.d.ts +1 -1
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit-diff.d.ts +33 -0
- package/dist/core/tools/edit-diff.d.ts.map +1 -0
- package/dist/core/tools/edit-diff.js +171 -0
- package/dist/core/tools/edit-diff.js.map +1 -0
- package/dist/core/tools/edit.d.ts +7 -1
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +20 -95
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/find.d.ts +1 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts +1 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/index.d.ts +1 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/ls.d.ts +1 -1
- package/dist/core/tools/ls.d.ts.map +1 -1
- package/dist/core/tools/ls.js.map +1 -1
- package/dist/core/tools/read.d.ts +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/write.d.ts +1 -1
- package/dist/core/tools/write.d.ts.map +1 -1
- package/dist/core/tools/write.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +22 -21
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/assistant-message.js +3 -4
- package/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +6 -2
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/bordered-loader.d.ts +12 -0
- package/dist/modes/interactive/components/bordered-loader.d.ts.map +1 -0
- package/dist/modes/interactive/components/bordered-loader.js +30 -0
- package/dist/modes/interactive/components/bordered-loader.js.map +1 -0
- package/dist/modes/interactive/components/branch-summary-message.d.ts +14 -0
- package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/branch-summary-message.js +35 -0
- package/dist/modes/interactive/components/branch-summary-message.js.map +1 -0
- package/dist/modes/interactive/components/compaction-summary-message.d.ts +14 -0
- package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/compaction-summary-message.js +36 -0
- package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -0
- package/dist/modes/interactive/components/dynamic-border.d.ts +5 -1
- package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
- package/dist/modes/interactive/components/dynamic-border.js +5 -1
- package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +12 -6
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +57 -25
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/hook-editor.d.ts +15 -0
- package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -0
- package/dist/modes/interactive/components/hook-editor.js +95 -0
- package/dist/modes/interactive/components/hook-editor.js.map +1 -0
- package/dist/modes/interactive/components/hook-message.d.ts +18 -0
- package/dist/modes/interactive/components/hook-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/hook-message.js +80 -0
- package/dist/modes/interactive/components/hook-message.js.map +1 -0
- package/dist/modes/interactive/components/model-selector.d.ts +3 -3
- package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/model-selector.js +1 -1
- package/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution.d.ts +15 -2
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +70 -21
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/components/tree-selector.d.ts +52 -0
- package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -0
- package/dist/modes/interactive/components/tree-selector.js +745 -0
- package/dist/modes/interactive/components/tree-selector.js.map +1 -0
- package/dist/modes/interactive/components/user-message-selector.d.ts +3 -3
- package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message-selector.js +1 -1
- package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
- package/dist/modes/interactive/components/user-message.d.ts +1 -1
- package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message.js +2 -5
- package/dist/modes/interactive/components/user-message.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +29 -12
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +589 -208
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/dark.json +13 -1
- package/dist/modes/interactive/theme/light.json +13 -1
- package/dist/modes/interactive/theme/theme-schema.json +34 -0
- package/dist/modes/interactive/theme/theme.d.ts +20 -2
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +135 -2
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/print-mode.d.ts +3 -3
- package/dist/modes/print-mode.d.ts.map +1 -1
- package/dist/modes/print-mode.js +26 -20
- package/dist/modes/print-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +13 -10
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +11 -10
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +88 -35
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +30 -11
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/utils/shell.d.ts +4 -2
- package/dist/utils/shell.d.ts.map +1 -1
- package/dist/utils/shell.js +36 -7
- package/dist/utils/shell.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +1 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +2 -2
- package/dist/utils/tools-manager.js.map +1 -1
- package/docs/compaction.md +388 -0
- package/docs/custom-tools.md +146 -43
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +562 -596
- package/docs/rpc.md +33 -19
- package/docs/sdk.md +93 -21
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +172 -21
- package/docs/skills.md +2 -0
- package/docs/theme.md +31 -2
- package/docs/tree.md +197 -0
- package/docs/tui.md +343 -0
- package/examples/README.md +1 -9
- package/examples/custom-tools/hello/index.ts +4 -3
- package/examples/custom-tools/question/index.ts +4 -4
- package/examples/custom-tools/subagent/index.ts +7 -6
- package/examples/custom-tools/todo/index.ts +11 -5
- package/examples/hooks/README.md +29 -71
- package/examples/hooks/auto-commit-on-exit.ts +8 -9
- package/examples/hooks/confirm-destructive.ts +29 -30
- package/examples/hooks/custom-compaction.ts +20 -21
- package/examples/hooks/dirty-repo-guard.ts +41 -40
- package/examples/hooks/file-trigger.ts +10 -5
- package/examples/hooks/git-checkpoint.ts +16 -12
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +1 -1
- package/examples/hooks/protected-paths.ts +1 -1
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/03-custom-prompt.ts +1 -1
- package/examples/sdk/04-skills.ts +1 -1
- package/examples/sdk/05-tools.ts +4 -4
- package/examples/sdk/06-hooks.ts +1 -1
- package/examples/sdk/07-context-files.ts +1 -1
- package/examples/sdk/08-slash-commands.ts +6 -1
- package/examples/sdk/09-api-keys-and-oauth.ts +1 -1
- package/examples/sdk/10-settings.ts +1 -1
- package/examples/sdk/11-sessions.ts +1 -1
- package/examples/sdk/12-full-control.ts +4 -7
- package/package.json +6 -6
- package/dist/core/compaction.d.ts.map +0 -1
- package/dist/core/compaction.js +0 -412
- package/dist/core/compaction.js.map +0 -1
- package/dist/core/export-html.d.ts +0 -23
- package/dist/core/export-html.d.ts.map +0 -1
- package/dist/core/export-html.js +0 -1185
- package/dist/core/export-html.js.map +0 -1
- package/dist/modes/interactive/components/compaction.d.ts +0 -15
- package/dist/modes/interactive/components/compaction.d.ts.map +0 -1
- package/dist/modes/interactive/components/compaction.js +0 -41
- package/dist/modes/interactive/components/compaction.js.map +0 -1
- package/docs/hooks-v2.md +0 -385
- package/docs/session-tree.md +0 -452
|
@@ -1,27 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs";
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from "fs";
|
|
3
3
|
import { join, resolve } from "path";
|
|
4
4
|
import { getAgentDir as getDefaultAgentDir } from "../config.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
import { createBranchSummaryMessage, createCompactionSummaryMessage, createHookMessage, } from "./messages.js";
|
|
6
|
+
export const CURRENT_SESSION_VERSION = 2;
|
|
7
|
+
/** Generate a unique short ID (8 hex chars, collision-checked) */
|
|
8
|
+
function generateId(byId) {
|
|
9
|
+
for (let i = 0; i < 100; i++) {
|
|
10
|
+
const id = randomUUID().slice(0, 8);
|
|
11
|
+
if (!byId.has(id))
|
|
12
|
+
return id;
|
|
13
|
+
}
|
|
14
|
+
// Fallback to full UUID if somehow we have collisions
|
|
15
|
+
return randomUUID();
|
|
11
16
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
|
|
18
|
+
function migrateV1ToV2(entries) {
|
|
19
|
+
const ids = new Set();
|
|
20
|
+
let prevId = null;
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.type === "session") {
|
|
23
|
+
entry.version = 2;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
entry.id = generateId(ids);
|
|
27
|
+
entry.parentId = prevId;
|
|
28
|
+
prevId = entry.id;
|
|
29
|
+
// Convert firstKeptEntryIndex to firstKeptEntryId for compaction
|
|
30
|
+
if (entry.type === "compaction") {
|
|
31
|
+
const comp = entry;
|
|
32
|
+
if (typeof comp.firstKeptEntryIndex === "number") {
|
|
33
|
+
const targetEntry = entries[comp.firstKeptEntryIndex];
|
|
34
|
+
if (targetEntry && targetEntry.type !== "session") {
|
|
35
|
+
comp.firstKeptEntryId = targetEntry.id;
|
|
36
|
+
}
|
|
37
|
+
delete comp.firstKeptEntryIndex;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Add future migrations here:
|
|
43
|
+
// function migrateV2ToV3(entries: FileEntry[]): void { ... }
|
|
44
|
+
/**
|
|
45
|
+
* Run all necessary migrations to bring entries to current version.
|
|
46
|
+
* Mutates entries in place. Returns true if any migration was applied.
|
|
47
|
+
*/
|
|
48
|
+
function migrateToCurrentVersion(entries) {
|
|
49
|
+
const header = entries.find((e) => e.type === "session");
|
|
50
|
+
const version = header?.version ?? 1;
|
|
51
|
+
if (version >= CURRENT_SESSION_VERSION)
|
|
52
|
+
return false;
|
|
53
|
+
if (version < 2)
|
|
54
|
+
migrateV1ToV2(entries);
|
|
55
|
+
// if (version < 3) migrateV2ToV3(entries);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
/** Exported for testing */
|
|
59
|
+
export function migrateSessionEntries(entries) {
|
|
60
|
+
migrateToCurrentVersion(entries);
|
|
25
61
|
}
|
|
26
62
|
/** Exported for compaction.test.ts */
|
|
27
63
|
export function parseSessionEntries(content) {
|
|
@@ -49,17 +85,46 @@ export function getLatestCompactionEntry(entries) {
|
|
|
49
85
|
return null;
|
|
50
86
|
}
|
|
51
87
|
/**
|
|
52
|
-
* Build the session context from entries
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* from `firstKeptEntryIndex` onwards. Otherwise returns all messages.
|
|
56
|
-
*
|
|
57
|
-
* Also extracts the current thinking level and model from the entries.
|
|
88
|
+
* Build the session context from entries using tree traversal.
|
|
89
|
+
* If leafId is provided, walks from that entry to root.
|
|
90
|
+
* Handles compaction and branch summaries along the path.
|
|
58
91
|
*/
|
|
59
|
-
export function buildSessionContext(entries) {
|
|
92
|
+
export function buildSessionContext(entries, leafId, byId) {
|
|
93
|
+
// Build uuid index if not available
|
|
94
|
+
if (!byId) {
|
|
95
|
+
byId = new Map();
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
byId.set(entry.id, entry);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Find leaf
|
|
101
|
+
let leaf;
|
|
102
|
+
if (leafId === null) {
|
|
103
|
+
// Explicitly null - return no messages (navigated to before first entry)
|
|
104
|
+
return { messages: [], thinkingLevel: "off", model: null };
|
|
105
|
+
}
|
|
106
|
+
if (leafId) {
|
|
107
|
+
leaf = byId.get(leafId);
|
|
108
|
+
}
|
|
109
|
+
if (!leaf) {
|
|
110
|
+
// Fallback to last entry (when leafId is undefined)
|
|
111
|
+
leaf = entries[entries.length - 1];
|
|
112
|
+
}
|
|
113
|
+
if (!leaf) {
|
|
114
|
+
return { messages: [], thinkingLevel: "off", model: null };
|
|
115
|
+
}
|
|
116
|
+
// Walk from leaf to root, collecting path
|
|
117
|
+
const path = [];
|
|
118
|
+
let current = leaf;
|
|
119
|
+
while (current) {
|
|
120
|
+
path.unshift(current);
|
|
121
|
+
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
122
|
+
}
|
|
123
|
+
// Extract settings and find compaction
|
|
60
124
|
let thinkingLevel = "off";
|
|
61
125
|
let model = null;
|
|
62
|
-
|
|
126
|
+
let compaction = null;
|
|
127
|
+
for (const entry of path) {
|
|
63
128
|
if (entry.type === "thinking_level_change") {
|
|
64
129
|
thinkingLevel = entry.thinkingLevel;
|
|
65
130
|
}
|
|
@@ -69,34 +134,55 @@ export function buildSessionContext(entries) {
|
|
|
69
134
|
else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
70
135
|
model = { provider: entry.message.provider, modelId: entry.message.model };
|
|
71
136
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
75
|
-
if (entries[i].type === "compaction") {
|
|
76
|
-
latestCompactionIndex = i;
|
|
77
|
-
break;
|
|
137
|
+
else if (entry.type === "compaction") {
|
|
138
|
+
compaction = entry;
|
|
78
139
|
}
|
|
79
140
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
141
|
+
// Build messages and collect corresponding entries
|
|
142
|
+
// When there's a compaction, we need to:
|
|
143
|
+
// 1. Emit summary first (entry = compaction)
|
|
144
|
+
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
|
|
145
|
+
// 3. Emit messages after compaction
|
|
146
|
+
const messages = [];
|
|
147
|
+
const appendMessage = (entry) => {
|
|
148
|
+
if (entry.type === "message") {
|
|
149
|
+
messages.push(entry.message);
|
|
150
|
+
}
|
|
151
|
+
else if (entry.type === "custom_message") {
|
|
152
|
+
messages.push(createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp));
|
|
153
|
+
}
|
|
154
|
+
else if (entry.type === "branch_summary" && entry.summary) {
|
|
155
|
+
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
if (compaction) {
|
|
159
|
+
// Emit summary first
|
|
160
|
+
messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
|
|
161
|
+
// Find compaction index in path
|
|
162
|
+
const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
|
|
163
|
+
// Emit kept messages (before compaction, starting from firstKeptEntryId)
|
|
164
|
+
let foundFirstKept = false;
|
|
165
|
+
for (let i = 0; i < compactionIdx; i++) {
|
|
166
|
+
const entry = path[i];
|
|
167
|
+
if (entry.id === compaction.firstKeptEntryId) {
|
|
168
|
+
foundFirstKept = true;
|
|
85
169
|
}
|
|
170
|
+
if (foundFirstKept) {
|
|
171
|
+
appendMessage(entry);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Emit messages after compaction
|
|
175
|
+
for (let i = compactionIdx + 1; i < path.length; i++) {
|
|
176
|
+
const entry = path[i];
|
|
177
|
+
appendMessage(entry);
|
|
86
178
|
}
|
|
87
|
-
return { messages, thinkingLevel, model };
|
|
88
179
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (entry.type === "message") {
|
|
94
|
-
keptMessages.push(entry.message);
|
|
180
|
+
else {
|
|
181
|
+
// No compaction - emit all messages, handle branch summaries and custom messages
|
|
182
|
+
for (const entry of path) {
|
|
183
|
+
appendMessage(entry);
|
|
95
184
|
}
|
|
96
185
|
}
|
|
97
|
-
const messages = [];
|
|
98
|
-
messages.push(createSummaryMessage(compactionEvent.summary));
|
|
99
|
-
messages.push(...keptMessages);
|
|
100
186
|
return { messages, thinkingLevel, model };
|
|
101
187
|
}
|
|
102
188
|
/**
|
|
@@ -111,7 +197,8 @@ function getDefaultSessionDir(cwd) {
|
|
|
111
197
|
}
|
|
112
198
|
return sessionDir;
|
|
113
199
|
}
|
|
114
|
-
|
|
200
|
+
/** Exported for testing */
|
|
201
|
+
export function loadEntriesFromFile(filePath) {
|
|
115
202
|
if (!existsSync(filePath))
|
|
116
203
|
return [];
|
|
117
204
|
const content = readFileSync(filePath, "utf8");
|
|
@@ -128,16 +215,39 @@ function loadEntriesFromFile(filePath) {
|
|
|
128
215
|
// Skip malformed lines
|
|
129
216
|
}
|
|
130
217
|
}
|
|
218
|
+
// Validate session header
|
|
219
|
+
if (entries.length === 0)
|
|
220
|
+
return entries;
|
|
221
|
+
const header = entries[0];
|
|
222
|
+
if (header.type !== "session" || typeof header.id !== "string") {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
131
225
|
return entries;
|
|
132
226
|
}
|
|
133
|
-
function
|
|
227
|
+
function isValidSessionFile(filePath) {
|
|
228
|
+
try {
|
|
229
|
+
const fd = openSync(filePath, "r");
|
|
230
|
+
const buffer = Buffer.alloc(512);
|
|
231
|
+
const bytesRead = readSync(fd, buffer, 0, 512, 0);
|
|
232
|
+
closeSync(fd);
|
|
233
|
+
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
|
|
234
|
+
if (!firstLine)
|
|
235
|
+
return false;
|
|
236
|
+
const header = JSON.parse(firstLine);
|
|
237
|
+
return header.type === "session" && typeof header.id === "string";
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Exported for testing */
|
|
244
|
+
export function findMostRecentSession(sessionDir) {
|
|
134
245
|
try {
|
|
135
246
|
const files = readdirSync(sessionDir)
|
|
136
247
|
.filter((f) => f.endsWith(".jsonl"))
|
|
137
|
-
.map((f) => (
|
|
138
|
-
|
|
139
|
-
mtime: statSync(
|
|
140
|
-
}))
|
|
248
|
+
.map((f) => join(sessionDir, f))
|
|
249
|
+
.filter(isValidSessionFile)
|
|
250
|
+
.map((path) => ({ path, mtime: statSync(path).mtime }))
|
|
141
251
|
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
142
252
|
return files[0]?.path || null;
|
|
143
253
|
}
|
|
@@ -145,53 +255,106 @@ function findMostRecentSession(sessionDir) {
|
|
|
145
255
|
return null;
|
|
146
256
|
}
|
|
147
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
260
|
+
*
|
|
261
|
+
* Each session entry has an id and parentId forming a tree structure. The "leaf"
|
|
262
|
+
* pointer tracks the current position. Appending creates a child of the current leaf.
|
|
263
|
+
* Branching moves the leaf to an earlier entry, allowing new branches without
|
|
264
|
+
* modifying history.
|
|
265
|
+
*
|
|
266
|
+
* Use buildSessionContext() to get the resolved message list for the LLM, which
|
|
267
|
+
* handles compaction summaries and follows the path from root to current leaf.
|
|
268
|
+
*/
|
|
148
269
|
export class SessionManager {
|
|
149
270
|
sessionId = "";
|
|
150
|
-
sessionFile
|
|
271
|
+
sessionFile;
|
|
151
272
|
sessionDir;
|
|
152
273
|
cwd;
|
|
153
274
|
persist;
|
|
154
275
|
flushed = false;
|
|
155
|
-
|
|
276
|
+
fileEntries = [];
|
|
277
|
+
byId = new Map();
|
|
278
|
+
labelsById = new Map();
|
|
279
|
+
leafId = null;
|
|
156
280
|
constructor(cwd, sessionDir, sessionFile, persist) {
|
|
157
281
|
this.cwd = cwd;
|
|
158
282
|
this.sessionDir = sessionDir;
|
|
283
|
+
this.persist = persist;
|
|
159
284
|
if (persist && sessionDir && !existsSync(sessionDir)) {
|
|
160
285
|
mkdirSync(sessionDir, { recursive: true });
|
|
161
286
|
}
|
|
162
|
-
this.persist = persist;
|
|
163
287
|
if (sessionFile) {
|
|
164
288
|
this.setSessionFile(sessionFile);
|
|
165
289
|
}
|
|
166
290
|
else {
|
|
167
|
-
this.
|
|
168
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
169
|
-
const sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`);
|
|
170
|
-
this.setSessionFile(sessionFile);
|
|
291
|
+
this.newSession();
|
|
171
292
|
}
|
|
172
293
|
}
|
|
173
294
|
/** Switch to a different session file (used for resume and branching) */
|
|
174
295
|
setSessionFile(sessionFile) {
|
|
175
296
|
this.sessionFile = resolve(sessionFile);
|
|
176
297
|
if (existsSync(this.sessionFile)) {
|
|
177
|
-
this.
|
|
178
|
-
const header = this.
|
|
179
|
-
this.sessionId = header
|
|
298
|
+
this.fileEntries = loadEntriesFromFile(this.sessionFile);
|
|
299
|
+
const header = this.fileEntries.find((e) => e.type === "session");
|
|
300
|
+
this.sessionId = header?.id ?? randomUUID();
|
|
301
|
+
if (migrateToCurrentVersion(this.fileEntries)) {
|
|
302
|
+
this._rewriteFile();
|
|
303
|
+
}
|
|
304
|
+
this._buildIndex();
|
|
180
305
|
this.flushed = true;
|
|
181
306
|
}
|
|
182
307
|
else {
|
|
183
|
-
this.
|
|
184
|
-
this.inMemoryEntries = [];
|
|
185
|
-
this.flushed = false;
|
|
186
|
-
const entry = {
|
|
187
|
-
type: "session",
|
|
188
|
-
id: this.sessionId,
|
|
189
|
-
timestamp: new Date().toISOString(),
|
|
190
|
-
cwd: this.cwd,
|
|
191
|
-
};
|
|
192
|
-
this.inMemoryEntries.push(entry);
|
|
308
|
+
this.newSession();
|
|
193
309
|
}
|
|
194
310
|
}
|
|
311
|
+
newSession(options) {
|
|
312
|
+
this.sessionId = randomUUID();
|
|
313
|
+
const timestamp = new Date().toISOString();
|
|
314
|
+
const header = {
|
|
315
|
+
type: "session",
|
|
316
|
+
version: CURRENT_SESSION_VERSION,
|
|
317
|
+
id: this.sessionId,
|
|
318
|
+
timestamp,
|
|
319
|
+
cwd: this.cwd,
|
|
320
|
+
parentSession: options?.parentSession,
|
|
321
|
+
};
|
|
322
|
+
this.fileEntries = [header];
|
|
323
|
+
this.byId.clear();
|
|
324
|
+
this.leafId = null;
|
|
325
|
+
this.flushed = false;
|
|
326
|
+
// Only generate filename if persisting and not already set (e.g., via --session flag)
|
|
327
|
+
if (this.persist && !this.sessionFile) {
|
|
328
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
329
|
+
this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
|
|
330
|
+
}
|
|
331
|
+
return this.sessionFile;
|
|
332
|
+
}
|
|
333
|
+
_buildIndex() {
|
|
334
|
+
this.byId.clear();
|
|
335
|
+
this.labelsById.clear();
|
|
336
|
+
this.leafId = null;
|
|
337
|
+
for (const entry of this.fileEntries) {
|
|
338
|
+
if (entry.type === "session")
|
|
339
|
+
continue;
|
|
340
|
+
this.byId.set(entry.id, entry);
|
|
341
|
+
this.leafId = entry.id;
|
|
342
|
+
if (entry.type === "label") {
|
|
343
|
+
if (entry.label) {
|
|
344
|
+
this.labelsById.set(entry.targetId, entry.label);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
this.labelsById.delete(entry.targetId);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
_rewriteFile() {
|
|
353
|
+
if (!this.persist || !this.sessionFile)
|
|
354
|
+
return;
|
|
355
|
+
const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
356
|
+
writeFileSync(this.sessionFile, content);
|
|
357
|
+
}
|
|
195
358
|
isPersisted() {
|
|
196
359
|
return this.persist;
|
|
197
360
|
}
|
|
@@ -207,28 +370,14 @@ export class SessionManager {
|
|
|
207
370
|
getSessionFile() {
|
|
208
371
|
return this.sessionFile;
|
|
209
372
|
}
|
|
210
|
-
reset() {
|
|
211
|
-
this.sessionId = uuidv4();
|
|
212
|
-
this.flushed = false;
|
|
213
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
214
|
-
this.sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`);
|
|
215
|
-
this.inMemoryEntries = [
|
|
216
|
-
{
|
|
217
|
-
type: "session",
|
|
218
|
-
id: this.sessionId,
|
|
219
|
-
timestamp: new Date().toISOString(),
|
|
220
|
-
cwd: this.cwd,
|
|
221
|
-
},
|
|
222
|
-
];
|
|
223
|
-
}
|
|
224
373
|
_persist(entry) {
|
|
225
|
-
if (!this.persist)
|
|
374
|
+
if (!this.persist || !this.sessionFile)
|
|
226
375
|
return;
|
|
227
|
-
const hasAssistant = this.
|
|
376
|
+
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
|
|
228
377
|
if (!hasAssistant)
|
|
229
378
|
return;
|
|
230
379
|
if (!this.flushed) {
|
|
231
|
-
for (const e of this.
|
|
380
|
+
for (const e of this.fileEntries) {
|
|
232
381
|
appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
|
|
233
382
|
}
|
|
234
383
|
this.flushed = true;
|
|
@@ -237,81 +386,364 @@ export class SessionManager {
|
|
|
237
386
|
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
238
387
|
}
|
|
239
388
|
}
|
|
240
|
-
|
|
389
|
+
_appendEntry(entry) {
|
|
390
|
+
this.fileEntries.push(entry);
|
|
391
|
+
this.byId.set(entry.id, entry);
|
|
392
|
+
this.leafId = entry.id;
|
|
393
|
+
this._persist(entry);
|
|
394
|
+
}
|
|
395
|
+
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
396
|
+
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
397
|
+
* Reason: we want these to be top-level entries in the session, not message session entries,
|
|
398
|
+
* so it is easier to find them.
|
|
399
|
+
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
400
|
+
*/
|
|
401
|
+
appendMessage(message) {
|
|
241
402
|
const entry = {
|
|
242
403
|
type: "message",
|
|
404
|
+
id: generateId(this.byId),
|
|
405
|
+
parentId: this.leafId,
|
|
243
406
|
timestamp: new Date().toISOString(),
|
|
244
407
|
message,
|
|
245
408
|
};
|
|
246
|
-
this.
|
|
247
|
-
|
|
409
|
+
this._appendEntry(entry);
|
|
410
|
+
return entry.id;
|
|
248
411
|
}
|
|
249
|
-
|
|
412
|
+
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
413
|
+
appendThinkingLevelChange(thinkingLevel) {
|
|
250
414
|
const entry = {
|
|
251
415
|
type: "thinking_level_change",
|
|
416
|
+
id: generateId(this.byId),
|
|
417
|
+
parentId: this.leafId,
|
|
252
418
|
timestamp: new Date().toISOString(),
|
|
253
419
|
thinkingLevel,
|
|
254
420
|
};
|
|
255
|
-
this.
|
|
256
|
-
|
|
421
|
+
this._appendEntry(entry);
|
|
422
|
+
return entry.id;
|
|
257
423
|
}
|
|
258
|
-
|
|
424
|
+
/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
|
|
425
|
+
appendModelChange(provider, modelId) {
|
|
259
426
|
const entry = {
|
|
260
427
|
type: "model_change",
|
|
428
|
+
id: generateId(this.byId),
|
|
429
|
+
parentId: this.leafId,
|
|
261
430
|
timestamp: new Date().toISOString(),
|
|
262
431
|
provider,
|
|
263
432
|
modelId,
|
|
264
433
|
};
|
|
265
|
-
this.
|
|
266
|
-
|
|
434
|
+
this._appendEntry(entry);
|
|
435
|
+
return entry.id;
|
|
267
436
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
437
|
+
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
438
|
+
appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook) {
|
|
439
|
+
const entry = {
|
|
440
|
+
type: "compaction",
|
|
441
|
+
id: generateId(this.byId),
|
|
442
|
+
parentId: this.leafId,
|
|
443
|
+
timestamp: new Date().toISOString(),
|
|
444
|
+
summary,
|
|
445
|
+
firstKeptEntryId,
|
|
446
|
+
tokensBefore,
|
|
447
|
+
details,
|
|
448
|
+
fromHook,
|
|
449
|
+
};
|
|
450
|
+
this._appendEntry(entry);
|
|
451
|
+
return entry.id;
|
|
452
|
+
}
|
|
453
|
+
/** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
|
|
454
|
+
appendCustomEntry(customType, data) {
|
|
455
|
+
const entry = {
|
|
456
|
+
type: "custom",
|
|
457
|
+
customType,
|
|
458
|
+
data,
|
|
459
|
+
id: generateId(this.byId),
|
|
460
|
+
parentId: this.leafId,
|
|
461
|
+
timestamp: new Date().toISOString(),
|
|
462
|
+
};
|
|
463
|
+
this._appendEntry(entry);
|
|
464
|
+
return entry.id;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Append a custom message entry (for hooks) that participates in LLM context.
|
|
468
|
+
* @param customType Hook identifier for filtering on reload
|
|
469
|
+
* @param content Message content (string or TextContent/ImageContent array)
|
|
470
|
+
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
|
471
|
+
* @param details Optional hook-specific metadata (not sent to LLM)
|
|
472
|
+
* @returns Entry id
|
|
473
|
+
*/
|
|
474
|
+
appendCustomMessageEntry(customType, content, display, details) {
|
|
475
|
+
const entry = {
|
|
476
|
+
type: "custom_message",
|
|
477
|
+
customType,
|
|
478
|
+
content,
|
|
479
|
+
display,
|
|
480
|
+
details,
|
|
481
|
+
id: generateId(this.byId),
|
|
482
|
+
parentId: this.leafId,
|
|
483
|
+
timestamp: new Date().toISOString(),
|
|
484
|
+
};
|
|
485
|
+
this._appendEntry(entry);
|
|
486
|
+
return entry.id;
|
|
487
|
+
}
|
|
488
|
+
// =========================================================================
|
|
489
|
+
// Tree Traversal
|
|
490
|
+
// =========================================================================
|
|
491
|
+
getLeafId() {
|
|
492
|
+
return this.leafId;
|
|
493
|
+
}
|
|
494
|
+
getLeafEntry() {
|
|
495
|
+
return this.leafId ? this.byId.get(this.leafId) : undefined;
|
|
496
|
+
}
|
|
497
|
+
getEntry(id) {
|
|
498
|
+
return this.byId.get(id);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Get all direct children of an entry.
|
|
502
|
+
*/
|
|
503
|
+
getChildren(parentId) {
|
|
504
|
+
const children = [];
|
|
505
|
+
for (const entry of this.byId.values()) {
|
|
506
|
+
if (entry.parentId === parentId) {
|
|
507
|
+
children.push(entry);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return children;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Get the label for an entry, if any.
|
|
514
|
+
*/
|
|
515
|
+
getLabel(id) {
|
|
516
|
+
return this.labelsById.get(id);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Set or clear a label on an entry.
|
|
520
|
+
* Labels are user-defined markers for bookmarking/navigation.
|
|
521
|
+
* Pass undefined or empty string to clear the label.
|
|
522
|
+
*/
|
|
523
|
+
appendLabelChange(targetId, label) {
|
|
524
|
+
if (!this.byId.has(targetId)) {
|
|
525
|
+
throw new Error(`Entry ${targetId} not found`);
|
|
526
|
+
}
|
|
527
|
+
const entry = {
|
|
528
|
+
type: "label",
|
|
529
|
+
id: generateId(this.byId),
|
|
530
|
+
parentId: this.leafId,
|
|
531
|
+
timestamp: new Date().toISOString(),
|
|
532
|
+
targetId,
|
|
533
|
+
label,
|
|
534
|
+
};
|
|
535
|
+
this._appendEntry(entry);
|
|
536
|
+
if (label) {
|
|
537
|
+
this.labelsById.set(targetId, label);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
this.labelsById.delete(targetId);
|
|
541
|
+
}
|
|
542
|
+
return entry.id;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Walk from entry to root, returning all entries in path order.
|
|
546
|
+
* Includes all entry types (messages, compaction, model changes, etc.).
|
|
547
|
+
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
548
|
+
*/
|
|
549
|
+
getBranch(fromId) {
|
|
550
|
+
const path = [];
|
|
551
|
+
const startId = fromId ?? this.leafId;
|
|
552
|
+
let current = startId ? this.byId.get(startId) : undefined;
|
|
553
|
+
while (current) {
|
|
554
|
+
path.unshift(current);
|
|
555
|
+
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
|
556
|
+
}
|
|
557
|
+
return path;
|
|
271
558
|
}
|
|
272
559
|
/**
|
|
273
560
|
* Build the session context (what gets sent to the LLM).
|
|
274
|
-
*
|
|
275
|
-
* Includes thinking level and model.
|
|
561
|
+
* Uses tree traversal from current leaf.
|
|
276
562
|
*/
|
|
277
563
|
buildSessionContext() {
|
|
278
|
-
return buildSessionContext(this.getEntries());
|
|
564
|
+
return buildSessionContext(this.getEntries(), this.leafId, this.byId);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Get session header.
|
|
568
|
+
*/
|
|
569
|
+
getHeader() {
|
|
570
|
+
const h = this.fileEntries.find((e) => e.type === "session");
|
|
571
|
+
return h ? h : null;
|
|
279
572
|
}
|
|
280
573
|
/**
|
|
281
|
-
* Get all session entries. Returns a
|
|
282
|
-
*
|
|
574
|
+
* Get all session entries (excludes header). Returns a shallow copy.
|
|
575
|
+
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
576
|
+
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
283
577
|
*/
|
|
284
578
|
getEntries() {
|
|
285
|
-
return
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
579
|
+
return this.fileEntries.filter((e) => e.type !== "session");
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Get the session as a tree structure. Returns a shallow defensive copy of all entries.
|
|
583
|
+
* A well-formed session has exactly one root (first entry with parentId === null).
|
|
584
|
+
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
585
|
+
*/
|
|
586
|
+
getTree() {
|
|
587
|
+
const entries = this.getEntries();
|
|
588
|
+
const nodeMap = new Map();
|
|
589
|
+
const roots = [];
|
|
590
|
+
// Create nodes with resolved labels
|
|
591
|
+
for (const entry of entries) {
|
|
592
|
+
const label = this.labelsById.get(entry.id);
|
|
593
|
+
nodeMap.set(entry.id, { entry, children: [], label });
|
|
594
|
+
}
|
|
595
|
+
// Build tree
|
|
596
|
+
for (const entry of entries) {
|
|
597
|
+
const node = nodeMap.get(entry.id);
|
|
598
|
+
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
599
|
+
roots.push(node);
|
|
301
600
|
}
|
|
302
601
|
else {
|
|
303
|
-
|
|
602
|
+
const parent = nodeMap.get(entry.parentId);
|
|
603
|
+
if (parent) {
|
|
604
|
+
parent.children.push(node);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
// Orphan - treat as root
|
|
608
|
+
roots.push(node);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Sort children by timestamp (oldest first, newest at bottom)
|
|
613
|
+
// Use iterative approach to avoid stack overflow on deep trees
|
|
614
|
+
const stack = [...roots];
|
|
615
|
+
while (stack.length > 0) {
|
|
616
|
+
const node = stack.pop();
|
|
617
|
+
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
618
|
+
stack.push(...node.children);
|
|
619
|
+
}
|
|
620
|
+
return roots;
|
|
621
|
+
}
|
|
622
|
+
// =========================================================================
|
|
623
|
+
// Branching
|
|
624
|
+
// =========================================================================
|
|
625
|
+
/**
|
|
626
|
+
* Start a new branch from an earlier entry.
|
|
627
|
+
* Moves the leaf pointer to the specified entry. The next appendXXX() call
|
|
628
|
+
* will create a child of that entry, forming a new branch. Existing entries
|
|
629
|
+
* are not modified or deleted.
|
|
630
|
+
*/
|
|
631
|
+
branch(branchFromId) {
|
|
632
|
+
if (!this.byId.has(branchFromId)) {
|
|
633
|
+
throw new Error(`Entry ${branchFromId} not found`);
|
|
634
|
+
}
|
|
635
|
+
this.leafId = branchFromId;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Reset the leaf pointer to null (before any entries).
|
|
639
|
+
* The next appendXXX() call will create a new root entry (parentId = null).
|
|
640
|
+
* Use this when navigating to re-edit the first user message.
|
|
641
|
+
*/
|
|
642
|
+
resetLeaf() {
|
|
643
|
+
this.leafId = null;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Start a new branch with a summary of the abandoned path.
|
|
647
|
+
* Same as branch(), but also appends a branch_summary entry that captures
|
|
648
|
+
* context from the abandoned conversation path.
|
|
649
|
+
*/
|
|
650
|
+
branchWithSummary(branchFromId, summary, details, fromHook) {
|
|
651
|
+
if (branchFromId !== null && !this.byId.has(branchFromId)) {
|
|
652
|
+
throw new Error(`Entry ${branchFromId} not found`);
|
|
653
|
+
}
|
|
654
|
+
this.leafId = branchFromId;
|
|
655
|
+
const entry = {
|
|
656
|
+
type: "branch_summary",
|
|
657
|
+
id: generateId(this.byId),
|
|
658
|
+
parentId: branchFromId,
|
|
659
|
+
timestamp: new Date().toISOString(),
|
|
660
|
+
fromId: branchFromId ?? "root",
|
|
661
|
+
summary,
|
|
662
|
+
details,
|
|
663
|
+
fromHook,
|
|
664
|
+
};
|
|
665
|
+
this._appendEntry(entry);
|
|
666
|
+
return entry.id;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Create a new session file containing only the path from root to the specified leaf.
|
|
670
|
+
* Useful for extracting a single conversation path from a branched session.
|
|
671
|
+
* Returns the new session file path, or undefined if not persisting.
|
|
672
|
+
*/
|
|
673
|
+
createBranchedSession(leafId) {
|
|
674
|
+
const path = this.getBranch(leafId);
|
|
675
|
+
if (path.length === 0) {
|
|
676
|
+
throw new Error(`Entry ${leafId} not found`);
|
|
677
|
+
}
|
|
678
|
+
// Filter out LabelEntry from path - we'll recreate them from the resolved map
|
|
679
|
+
const pathWithoutLabels = path.filter((e) => e.type !== "label");
|
|
680
|
+
const newSessionId = randomUUID();
|
|
681
|
+
const timestamp = new Date().toISOString();
|
|
682
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
683
|
+
const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
|
|
684
|
+
const header = {
|
|
685
|
+
type: "session",
|
|
686
|
+
version: CURRENT_SESSION_VERSION,
|
|
687
|
+
id: newSessionId,
|
|
688
|
+
timestamp,
|
|
689
|
+
cwd: this.cwd,
|
|
690
|
+
parentSession: this.persist ? this.sessionFile : undefined,
|
|
691
|
+
};
|
|
692
|
+
// Collect labels for entries in the path
|
|
693
|
+
const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));
|
|
694
|
+
const labelsToWrite = [];
|
|
695
|
+
for (const [targetId, label] of this.labelsById) {
|
|
696
|
+
if (pathEntryIds.has(targetId)) {
|
|
697
|
+
labelsToWrite.push({ targetId, label });
|
|
304
698
|
}
|
|
305
699
|
}
|
|
306
700
|
if (this.persist) {
|
|
307
|
-
|
|
701
|
+
appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`);
|
|
702
|
+
for (const entry of pathWithoutLabels) {
|
|
308
703
|
appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
|
|
309
704
|
}
|
|
705
|
+
// Write fresh label entries at the end
|
|
706
|
+
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
707
|
+
let parentId = lastEntryId;
|
|
708
|
+
const labelEntries = [];
|
|
709
|
+
for (const { targetId, label } of labelsToWrite) {
|
|
710
|
+
const labelEntry = {
|
|
711
|
+
type: "label",
|
|
712
|
+
id: generateId(new Set(pathEntryIds)),
|
|
713
|
+
parentId,
|
|
714
|
+
timestamp: new Date().toISOString(),
|
|
715
|
+
targetId,
|
|
716
|
+
label,
|
|
717
|
+
};
|
|
718
|
+
appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
|
|
719
|
+
pathEntryIds.add(labelEntry.id);
|
|
720
|
+
labelEntries.push(labelEntry);
|
|
721
|
+
parentId = labelEntry.id;
|
|
722
|
+
}
|
|
723
|
+
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
724
|
+
this.sessionId = newSessionId;
|
|
725
|
+
this._buildIndex();
|
|
310
726
|
return newSessionFile;
|
|
311
727
|
}
|
|
312
|
-
|
|
728
|
+
// In-memory mode: replace current session with the path + labels
|
|
729
|
+
const labelEntries = [];
|
|
730
|
+
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
731
|
+
for (const { targetId, label } of labelsToWrite) {
|
|
732
|
+
const labelEntry = {
|
|
733
|
+
type: "label",
|
|
734
|
+
id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
|
|
735
|
+
parentId,
|
|
736
|
+
timestamp: new Date().toISOString(),
|
|
737
|
+
targetId,
|
|
738
|
+
label,
|
|
739
|
+
};
|
|
740
|
+
labelEntries.push(labelEntry);
|
|
741
|
+
parentId = labelEntry.id;
|
|
742
|
+
}
|
|
743
|
+
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
313
744
|
this.sessionId = newSessionId;
|
|
314
|
-
|
|
745
|
+
this._buildIndex();
|
|
746
|
+
return undefined;
|
|
315
747
|
}
|
|
316
748
|
/**
|
|
317
749
|
* Create a new session.
|
|
@@ -320,7 +752,7 @@ export class SessionManager {
|
|
|
320
752
|
*/
|
|
321
753
|
static create(cwd, sessionDir) {
|
|
322
754
|
const dir = sessionDir ?? getDefaultSessionDir(cwd);
|
|
323
|
-
return new SessionManager(cwd, dir,
|
|
755
|
+
return new SessionManager(cwd, dir, undefined, true);
|
|
324
756
|
}
|
|
325
757
|
/**
|
|
326
758
|
* Open a specific session file.
|
|
@@ -347,11 +779,11 @@ export class SessionManager {
|
|
|
347
779
|
if (mostRecent) {
|
|
348
780
|
return new SessionManager(cwd, dir, mostRecent, true);
|
|
349
781
|
}
|
|
350
|
-
return new SessionManager(cwd, dir,
|
|
782
|
+
return new SessionManager(cwd, dir, undefined, true);
|
|
351
783
|
}
|
|
352
784
|
/** Create an in-memory session (no file persistence) */
|
|
353
785
|
static inMemory(cwd = process.cwd()) {
|
|
354
|
-
return new SessionManager(cwd, "",
|
|
786
|
+
return new SessionManager(cwd, "", undefined, false);
|
|
355
787
|
}
|
|
356
788
|
/**
|
|
357
789
|
* List all sessions.
|