@mariozechner/pi-coding-agent 0.30.1 → 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 +251 -2
- 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 -7
- package/dist/core/auth-storage.d.ts.map +1 -1
- package/dist/core/auth-storage.js +4 -52
- 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 +25 -25
- package/dist/main.js.map +1 -1
- package/dist/migrations.d.ts +28 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +125 -0
- package/dist/migrations.js.map +1 -0
- 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,37 +1,109 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
|
|
3
|
+
import { type BashExecutionMessage, type HookMessage } from "./messages.js";
|
|
4
|
+
export declare const CURRENT_SESSION_VERSION = 2;
|
|
2
5
|
export interface SessionHeader {
|
|
3
6
|
type: "session";
|
|
7
|
+
version?: number;
|
|
4
8
|
id: string;
|
|
5
9
|
timestamp: string;
|
|
6
10
|
cwd: string;
|
|
7
|
-
|
|
11
|
+
parentSession?: string;
|
|
8
12
|
}
|
|
9
|
-
export interface
|
|
10
|
-
|
|
13
|
+
export interface NewSessionOptions {
|
|
14
|
+
parentSession?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SessionEntryBase {
|
|
17
|
+
type: string;
|
|
18
|
+
id: string;
|
|
19
|
+
parentId: string | null;
|
|
11
20
|
timestamp: string;
|
|
12
|
-
message: AppMessage;
|
|
13
21
|
}
|
|
14
|
-
export interface
|
|
22
|
+
export interface SessionMessageEntry extends SessionEntryBase {
|
|
23
|
+
type: "message";
|
|
24
|
+
message: AgentMessage;
|
|
25
|
+
}
|
|
26
|
+
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
|
|
15
27
|
type: "thinking_level_change";
|
|
16
|
-
timestamp: string;
|
|
17
28
|
thinkingLevel: string;
|
|
18
29
|
}
|
|
19
|
-
export interface ModelChangeEntry {
|
|
30
|
+
export interface ModelChangeEntry extends SessionEntryBase {
|
|
20
31
|
type: "model_change";
|
|
21
|
-
timestamp: string;
|
|
22
32
|
provider: string;
|
|
23
33
|
modelId: string;
|
|
24
34
|
}
|
|
25
|
-
export interface CompactionEntry {
|
|
35
|
+
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
26
36
|
type: "compaction";
|
|
27
|
-
timestamp: string;
|
|
28
37
|
summary: string;
|
|
29
|
-
|
|
38
|
+
firstKeptEntryId: string;
|
|
30
39
|
tokensBefore: number;
|
|
40
|
+
/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
41
|
+
details?: T;
|
|
42
|
+
/** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
|
|
43
|
+
fromHook?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
|
|
46
|
+
type: "branch_summary";
|
|
47
|
+
fromId: string;
|
|
48
|
+
summary: string;
|
|
49
|
+
/** Hook-specific data (not sent to LLM) */
|
|
50
|
+
details?: T;
|
|
51
|
+
/** True if generated by a hook, false if pi-generated */
|
|
52
|
+
fromHook?: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Custom entry for hooks to store hook-specific data in the session.
|
|
56
|
+
* Use customType to identify your hook's entries.
|
|
57
|
+
*
|
|
58
|
+
* Purpose: Persist hook state across session reloads. On reload, hooks can
|
|
59
|
+
* scan entries for their customType and reconstruct internal state.
|
|
60
|
+
*
|
|
61
|
+
* Does NOT participate in LLM context (ignored by buildSessionContext).
|
|
62
|
+
* For injecting content into context, see CustomMessageEntry.
|
|
63
|
+
*/
|
|
64
|
+
export interface CustomEntry<T = unknown> extends SessionEntryBase {
|
|
65
|
+
type: "custom";
|
|
66
|
+
customType: string;
|
|
67
|
+
data?: T;
|
|
68
|
+
}
|
|
69
|
+
/** Label entry for user-defined bookmarks/markers on entries. */
|
|
70
|
+
export interface LabelEntry extends SessionEntryBase {
|
|
71
|
+
type: "label";
|
|
72
|
+
targetId: string;
|
|
73
|
+
label: string | undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Custom message entry for hooks to inject messages into LLM context.
|
|
77
|
+
* Use customType to identify your hook's entries.
|
|
78
|
+
*
|
|
79
|
+
* Unlike CustomEntry, this DOES participate in LLM context.
|
|
80
|
+
* The content is converted to a user message in buildSessionContext().
|
|
81
|
+
* Use details for hook-specific metadata (not sent to LLM).
|
|
82
|
+
*
|
|
83
|
+
* display controls TUI rendering:
|
|
84
|
+
* - false: hidden entirely
|
|
85
|
+
* - true: rendered with distinct styling (different from user messages)
|
|
86
|
+
*/
|
|
87
|
+
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
|
88
|
+
type: "custom_message";
|
|
89
|
+
customType: string;
|
|
90
|
+
content: string | (TextContent | ImageContent)[];
|
|
91
|
+
details?: T;
|
|
92
|
+
display: boolean;
|
|
93
|
+
}
|
|
94
|
+
/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
|
|
95
|
+
export type SessionEntry = SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | LabelEntry;
|
|
96
|
+
/** Raw file entry (includes header) */
|
|
97
|
+
export type FileEntry = SessionHeader | SessionEntry;
|
|
98
|
+
/** Tree node for getTree() - defensive copy of session structure */
|
|
99
|
+
export interface SessionTreeNode {
|
|
100
|
+
entry: SessionEntry;
|
|
101
|
+
children: SessionTreeNode[];
|
|
102
|
+
/** Resolved label for this entry, if any */
|
|
103
|
+
label?: string;
|
|
31
104
|
}
|
|
32
|
-
export type SessionEntry = SessionHeader | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | CompactionEntry;
|
|
33
105
|
export interface SessionContext {
|
|
34
|
-
messages:
|
|
106
|
+
messages: AgentMessage[];
|
|
35
107
|
thinkingLevel: string;
|
|
36
108
|
model: {
|
|
37
109
|
provider: string;
|
|
@@ -47,22 +119,33 @@ export interface SessionInfo {
|
|
|
47
119
|
firstMessage: string;
|
|
48
120
|
allMessagesText: string;
|
|
49
121
|
}
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
export declare function createSummaryMessage(summary: string): AppMessage;
|
|
122
|
+
export type ReadonlySessionManager = Pick<SessionManager, "getCwd" | "getSessionDir" | "getSessionId" | "getSessionFile" | "getLeafId" | "getLeafEntry" | "getEntry" | "getLabel" | "getBranch" | "getHeader" | "getEntries" | "getTree">;
|
|
123
|
+
/** Exported for testing */
|
|
124
|
+
export declare function migrateSessionEntries(entries: FileEntry[]): void;
|
|
54
125
|
/** Exported for compaction.test.ts */
|
|
55
|
-
export declare function parseSessionEntries(content: string):
|
|
126
|
+
export declare function parseSessionEntries(content: string): FileEntry[];
|
|
56
127
|
export declare function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null;
|
|
57
128
|
/**
|
|
58
|
-
* Build the session context from entries
|
|
129
|
+
* Build the session context from entries using tree traversal.
|
|
130
|
+
* If leafId is provided, walks from that entry to root.
|
|
131
|
+
* Handles compaction and branch summaries along the path.
|
|
132
|
+
*/
|
|
133
|
+
export declare function buildSessionContext(entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>): SessionContext;
|
|
134
|
+
/** Exported for testing */
|
|
135
|
+
export declare function loadEntriesFromFile(filePath: string): FileEntry[];
|
|
136
|
+
/** Exported for testing */
|
|
137
|
+
export declare function findMostRecentSession(sessionDir: string): string | null;
|
|
138
|
+
/**
|
|
139
|
+
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
59
140
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
141
|
+
* Each session entry has an id and parentId forming a tree structure. The "leaf"
|
|
142
|
+
* pointer tracks the current position. Appending creates a child of the current leaf.
|
|
143
|
+
* Branching moves the leaf to an earlier entry, allowing new branches without
|
|
144
|
+
* modifying history.
|
|
62
145
|
*
|
|
63
|
-
*
|
|
146
|
+
* Use buildSessionContext() to get the resolved message list for the LLM, which
|
|
147
|
+
* handles compaction summaries and follows the path from root to current leaf.
|
|
64
148
|
*/
|
|
65
|
-
export declare function buildSessionContext(entries: SessionEntry[]): SessionContext;
|
|
66
149
|
export declare class SessionManager {
|
|
67
150
|
private sessionId;
|
|
68
151
|
private sessionFile;
|
|
@@ -70,33 +153,116 @@ export declare class SessionManager {
|
|
|
70
153
|
private cwd;
|
|
71
154
|
private persist;
|
|
72
155
|
private flushed;
|
|
73
|
-
private
|
|
156
|
+
private fileEntries;
|
|
157
|
+
private byId;
|
|
158
|
+
private labelsById;
|
|
159
|
+
private leafId;
|
|
74
160
|
private constructor();
|
|
75
161
|
/** Switch to a different session file (used for resume and branching) */
|
|
76
162
|
setSessionFile(sessionFile: string): void;
|
|
163
|
+
newSession(options?: NewSessionOptions): string | undefined;
|
|
164
|
+
private _buildIndex;
|
|
165
|
+
private _rewriteFile;
|
|
77
166
|
isPersisted(): boolean;
|
|
78
167
|
getCwd(): string;
|
|
79
168
|
getSessionDir(): string;
|
|
80
169
|
getSessionId(): string;
|
|
81
|
-
getSessionFile(): string;
|
|
82
|
-
reset(): void;
|
|
170
|
+
getSessionFile(): string | undefined;
|
|
83
171
|
_persist(entry: SessionEntry): void;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
172
|
+
private _appendEntry;
|
|
173
|
+
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
174
|
+
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
175
|
+
* Reason: we want these to be top-level entries in the session, not message session entries,
|
|
176
|
+
* so it is easier to find them.
|
|
177
|
+
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
178
|
+
*/
|
|
179
|
+
appendMessage(message: Message | HookMessage | BashExecutionMessage): string;
|
|
180
|
+
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
181
|
+
appendThinkingLevelChange(thinkingLevel: string): string;
|
|
182
|
+
/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
|
|
183
|
+
appendModelChange(provider: string, modelId: string): string;
|
|
184
|
+
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
185
|
+
appendCompaction<T = unknown>(summary: string, firstKeptEntryId: string, tokensBefore: number, details?: T, fromHook?: boolean): string;
|
|
186
|
+
/** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
|
|
187
|
+
appendCustomEntry(customType: string, data?: unknown): string;
|
|
188
|
+
/**
|
|
189
|
+
* Append a custom message entry (for hooks) that participates in LLM context.
|
|
190
|
+
* @param customType Hook identifier for filtering on reload
|
|
191
|
+
* @param content Message content (string or TextContent/ImageContent array)
|
|
192
|
+
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
|
193
|
+
* @param details Optional hook-specific metadata (not sent to LLM)
|
|
194
|
+
* @returns Entry id
|
|
195
|
+
*/
|
|
196
|
+
appendCustomMessageEntry<T = unknown>(customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details?: T): string;
|
|
197
|
+
getLeafId(): string | null;
|
|
198
|
+
getLeafEntry(): SessionEntry | undefined;
|
|
199
|
+
getEntry(id: string): SessionEntry | undefined;
|
|
200
|
+
/**
|
|
201
|
+
* Get all direct children of an entry.
|
|
202
|
+
*/
|
|
203
|
+
getChildren(parentId: string): SessionEntry[];
|
|
204
|
+
/**
|
|
205
|
+
* Get the label for an entry, if any.
|
|
206
|
+
*/
|
|
207
|
+
getLabel(id: string): string | undefined;
|
|
208
|
+
/**
|
|
209
|
+
* Set or clear a label on an entry.
|
|
210
|
+
* Labels are user-defined markers for bookmarking/navigation.
|
|
211
|
+
* Pass undefined or empty string to clear the label.
|
|
212
|
+
*/
|
|
213
|
+
appendLabelChange(targetId: string, label: string | undefined): string;
|
|
214
|
+
/**
|
|
215
|
+
* Walk from entry to root, returning all entries in path order.
|
|
216
|
+
* Includes all entry types (messages, compaction, model changes, etc.).
|
|
217
|
+
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
218
|
+
*/
|
|
219
|
+
getBranch(fromId?: string): SessionEntry[];
|
|
88
220
|
/**
|
|
89
221
|
* Build the session context (what gets sent to the LLM).
|
|
90
|
-
*
|
|
91
|
-
* Includes thinking level and model.
|
|
222
|
+
* Uses tree traversal from current leaf.
|
|
92
223
|
*/
|
|
93
224
|
buildSessionContext(): SessionContext;
|
|
94
225
|
/**
|
|
95
|
-
* Get
|
|
96
|
-
|
|
226
|
+
* Get session header.
|
|
227
|
+
*/
|
|
228
|
+
getHeader(): SessionHeader | null;
|
|
229
|
+
/**
|
|
230
|
+
* Get all session entries (excludes header). Returns a shallow copy.
|
|
231
|
+
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
232
|
+
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
97
233
|
*/
|
|
98
234
|
getEntries(): SessionEntry[];
|
|
99
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Get the session as a tree structure. Returns a shallow defensive copy of all entries.
|
|
237
|
+
* A well-formed session has exactly one root (first entry with parentId === null).
|
|
238
|
+
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
239
|
+
*/
|
|
240
|
+
getTree(): SessionTreeNode[];
|
|
241
|
+
/**
|
|
242
|
+
* Start a new branch from an earlier entry.
|
|
243
|
+
* Moves the leaf pointer to the specified entry. The next appendXXX() call
|
|
244
|
+
* will create a child of that entry, forming a new branch. Existing entries
|
|
245
|
+
* are not modified or deleted.
|
|
246
|
+
*/
|
|
247
|
+
branch(branchFromId: string): void;
|
|
248
|
+
/**
|
|
249
|
+
* Reset the leaf pointer to null (before any entries).
|
|
250
|
+
* The next appendXXX() call will create a new root entry (parentId = null).
|
|
251
|
+
* Use this when navigating to re-edit the first user message.
|
|
252
|
+
*/
|
|
253
|
+
resetLeaf(): void;
|
|
254
|
+
/**
|
|
255
|
+
* Start a new branch with a summary of the abandoned path.
|
|
256
|
+
* Same as branch(), but also appends a branch_summary entry that captures
|
|
257
|
+
* context from the abandoned conversation path.
|
|
258
|
+
*/
|
|
259
|
+
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string;
|
|
260
|
+
/**
|
|
261
|
+
* Create a new session file containing only the path from root to the specified leaf.
|
|
262
|
+
* Useful for extracting a single conversation path from a branched session.
|
|
263
|
+
* Returns the new session file path, or undefined if not persisting.
|
|
264
|
+
*/
|
|
265
|
+
createBranchedSession(leafId: string): string | undefined;
|
|
100
266
|
/**
|
|
101
267
|
* Create a new session.
|
|
102
268
|
* @param cwd Working directory (stored in session header)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/core/session-manager.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AppMessage } from \"@mariozechner/pi-agent-core\";\nimport { randomBytes } from \"crypto\";\nimport { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport { join, resolve } from \"path\";\nimport { getAgentDir as getDefaultAgentDir } from \"../config.js\";\n\nfunction uuidv4(): string {\n\tconst bytes = randomBytes(16);\n\tbytes[6] = (bytes[6] & 0x0f) | 0x40;\n\tbytes[8] = (bytes[8] & 0x3f) | 0x80;\n\tconst hex = bytes.toString(\"hex\");\n\treturn `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;\n}\n\nexport interface SessionHeader {\n\ttype: \"session\";\n\tid: string;\n\ttimestamp: string;\n\tcwd: string;\n\tbranchedFrom?: string;\n}\n\nexport interface SessionMessageEntry {\n\ttype: \"message\";\n\ttimestamp: string;\n\tmessage: AppMessage;\n}\n\nexport interface ThinkingLevelChangeEntry {\n\ttype: \"thinking_level_change\";\n\ttimestamp: string;\n\tthinkingLevel: string;\n}\n\nexport interface ModelChangeEntry {\n\ttype: \"model_change\";\n\ttimestamp: string;\n\tprovider: string;\n\tmodelId: string;\n}\n\nexport interface CompactionEntry {\n\ttype: \"compaction\";\n\ttimestamp: string;\n\tsummary: string;\n\tfirstKeptEntryIndex: number;\n\ttokensBefore: number;\n}\n\nexport type SessionEntry =\n\t| SessionHeader\n\t| SessionMessageEntry\n\t| ThinkingLevelChangeEntry\n\t| ModelChangeEntry\n\t| CompactionEntry;\n\nexport interface SessionContext {\n\tmessages: AppMessage[];\n\tthinkingLevel: string;\n\tmodel: { provider: string; modelId: string } | null;\n}\n\nexport interface SessionInfo {\n\tpath: string;\n\tid: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\nexport const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:\n\n<summary>\n`;\n\nexport const SUMMARY_SUFFIX = `\n</summary>`;\n\n/** Exported for compaction.test.ts */\nexport function createSummaryMessage(summary: string): AppMessage {\n\treturn {\n\t\trole: \"user\",\n\t\tcontent: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX,\n\t\ttimestamp: Date.now(),\n\t};\n}\n\n/** Exported for compaction.test.ts */\nexport function parseSessionEntries(content: string): SessionEntry[] {\n\tconst entries: SessionEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as SessionEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nexport function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\treturn entries[i] as CompactionEntry;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Build the session context from entries. This is what gets sent to the LLM.\n *\n * If there's a compaction entry, returns the summary message plus messages\n * from `firstKeptEntryIndex` onwards. Otherwise returns all messages.\n *\n * Also extracts the current thinking level and model from the entries.\n */\nexport function buildSessionContext(entries: SessionEntry[]): SessionContext {\n\tlet thinkingLevel = \"off\";\n\tlet model: { provider: string; modelId: string } | null = null;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"thinking_level_change\") {\n\t\t\tthinkingLevel = entry.thinkingLevel;\n\t\t} else if (entry.type === \"model_change\") {\n\t\t\tmodel = { provider: entry.provider, modelId: entry.modelId };\n\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\tmodel = { provider: entry.message.provider, modelId: entry.message.model };\n\t\t}\n\t}\n\n\tlet latestCompactionIndex = -1;\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\tlatestCompactionIndex = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\n\tif (latestCompactionIndex === -1) {\n\t\tconst messages: AppMessage[] = [];\n\t\tfor (const entry of entries) {\n\t\t\tif (entry.type === \"message\") {\n\t\t\t\tmessages.push(entry.message);\n\t\t\t}\n\t\t}\n\t\treturn { messages, thinkingLevel, model };\n\t}\n\n\tconst compactionEvent = entries[latestCompactionIndex] as CompactionEntry;\n\n\tconst keptMessages: AppMessage[] = [];\n\tfor (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) {\n\t\tconst entry = entries[i];\n\t\tif (entry.type === \"message\") {\n\t\t\tkeptMessages.push(entry.message);\n\t\t}\n\t}\n\n\tconst messages: AppMessage[] = [];\n\tmessages.push(createSummaryMessage(compactionEvent.summary));\n\tmessages.push(...keptMessages);\n\n\treturn { messages, thinkingLevel, model };\n}\n\n/**\n * Compute the default session directory for a cwd.\n * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.\n */\nfunction getDefaultSessionDir(cwd: string): string {\n\tconst safePath = `--${cwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\tconst sessionDir = join(getDefaultAgentDir(), \"sessions\", safePath);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\nfunction loadEntriesFromFile(filePath: string): SessionEntry[] {\n\tif (!existsSync(filePath)) return [];\n\n\tconst content = readFileSync(filePath, \"utf8\");\n\tconst entries: SessionEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as SessionEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nfunction findMostRecentSession(sessionDir: string): string | null {\n\ttry {\n\t\tconst files = readdirSync(sessionDir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => ({\n\t\t\t\tpath: join(sessionDir, f),\n\t\t\t\tmtime: statSync(join(sessionDir, f)).mtime,\n\t\t\t}))\n\t\t\t.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\n\t\treturn files[0]?.path || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nexport class SessionManager {\n\tprivate sessionId: string = \"\";\n\tprivate sessionFile: string = \"\";\n\tprivate sessionDir: string;\n\tprivate cwd: string;\n\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate inMemoryEntries: SessionEntry[] = [];\n\n\tprivate constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) {\n\t\tthis.cwd = cwd;\n\t\tthis.sessionDir = sessionDir;\n\t\tif (persist && sessionDir && !existsSync(sessionDir)) {\n\t\t\tmkdirSync(sessionDir, { recursive: true });\n\t\t}\n\t\tthis.persist = persist;\n\n\t\tif (sessionFile) {\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t} else {\n\t\t\tthis.sessionId = uuidv4();\n\t\t\tconst timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n\t\t\tconst sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`);\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t}\n\t}\n\n\t/** Switch to a different session file (used for resume and branching) */\n\tsetSessionFile(sessionFile: string): void {\n\t\tthis.sessionFile = resolve(sessionFile);\n\t\tif (existsSync(this.sessionFile)) {\n\t\t\tthis.inMemoryEntries = loadEntriesFromFile(this.sessionFile);\n\t\t\tconst header = this.inMemoryEntries.find((e) => e.type === \"session\");\n\t\t\tthis.sessionId = header ? (header as SessionHeader).id : uuidv4();\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tthis.sessionId = uuidv4();\n\t\t\tthis.inMemoryEntries = [];\n\t\t\tthis.flushed = false;\n\t\t\tconst entry: SessionHeader = {\n\t\t\t\ttype: \"session\",\n\t\t\t\tid: this.sessionId,\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\tcwd: this.cwd,\n\t\t\t};\n\t\t\tthis.inMemoryEntries.push(entry);\n\t\t}\n\t}\n\n\tisPersisted(): boolean {\n\t\treturn this.persist;\n\t}\n\n\tgetCwd(): string {\n\t\treturn this.cwd;\n\t}\n\n\tgetSessionDir(): string {\n\t\treturn this.sessionDir;\n\t}\n\n\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string {\n\t\treturn this.sessionFile;\n\t}\n\n\treset(): void {\n\t\tthis.sessionId = uuidv4();\n\t\tthis.flushed = false;\n\t\tconst timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n\t\tthis.sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`);\n\t\tthis.inMemoryEntries = [\n\t\t\t{\n\t\t\t\ttype: \"session\",\n\t\t\t\tid: this.sessionId,\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\tcwd: this.cwd,\n\t\t\t},\n\t\t];\n\t}\n\n\t_persist(entry: SessionEntry): void {\n\t\tif (!this.persist) return;\n\n\t\tconst hasAssistant = this.inMemoryEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\tif (!hasAssistant) return;\n\n\t\tif (!this.flushed) {\n\t\t\tfor (const e of this.inMemoryEntries) {\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(e)}\\n`);\n\t\t\t}\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t}\n\t}\n\n\tsaveMessage(message: AppMessage): void {\n\t\tconst entry: SessionMessageEntry = {\n\t\t\ttype: \"message\",\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tmessage,\n\t\t};\n\t\tthis.inMemoryEntries.push(entry);\n\t\tthis._persist(entry);\n\t}\n\n\tsaveThinkingLevelChange(thinkingLevel: string): void {\n\t\tconst entry: ThinkingLevelChangeEntry = {\n\t\t\ttype: \"thinking_level_change\",\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tthinkingLevel,\n\t\t};\n\t\tthis.inMemoryEntries.push(entry);\n\t\tthis._persist(entry);\n\t}\n\n\tsaveModelChange(provider: string, modelId: string): void {\n\t\tconst entry: ModelChangeEntry = {\n\t\t\ttype: \"model_change\",\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tprovider,\n\t\t\tmodelId,\n\t\t};\n\t\tthis.inMemoryEntries.push(entry);\n\t\tthis._persist(entry);\n\t}\n\n\tsaveCompaction(entry: CompactionEntry): void {\n\t\tthis.inMemoryEntries.push(entry);\n\t\tthis._persist(entry);\n\t}\n\n\t/**\n\t * Build the session context (what gets sent to the LLM).\n\t * If compacted, returns summary + kept messages. Otherwise all messages.\n\t * Includes thinking level and model.\n\t */\n\tbuildSessionContext(): SessionContext {\n\t\treturn buildSessionContext(this.getEntries());\n\t}\n\n\t/**\n\t * Get all session entries. Returns a defensive copy.\n\t * Use buildSessionContext() if you need the messages for the LLM.\n\t */\n\tgetEntries(): SessionEntry[] {\n\t\treturn [...this.inMemoryEntries];\n\t}\n\n\tcreateBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null {\n\t\tconst newSessionId = uuidv4();\n\t\tconst timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`);\n\n\t\tconst newEntries: SessionEntry[] = [];\n\t\tfor (let i = 0; i < branchBeforeIndex; i++) {\n\t\t\tconst entry = entries[i];\n\n\t\t\tif (entry.type === \"session\") {\n\t\t\t\tnewEntries.push({\n\t\t\t\t\t...entry,\n\t\t\t\t\tid: newSessionId,\n\t\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\t\tbranchedFrom: this.persist ? this.sessionFile : undefined,\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tnewEntries.push(entry);\n\t\t\t}\n\t\t}\n\n\t\tif (this.persist) {\n\t\t\tfor (const entry of newEntries) {\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t\treturn newSessionFile;\n\t\t}\n\t\tthis.inMemoryEntries = newEntries;\n\t\tthis.sessionId = newSessionId;\n\t\treturn null;\n\t}\n\n\t/**\n\t * Create a new session.\n\t * @param cwd Working directory (stored in session header)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\treturn new SessionManager(cwd, dir, null, true);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\n\t * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.\n\t */\n\tstatic open(path: string, sessionDir?: string): SessionManager {\n\t\t// Extract cwd from session header if possible, otherwise use process.cwd()\n\t\tconst entries = loadEntriesFromFile(path);\n\t\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tconst cwd = header?.cwd ?? process.cwd();\n\t\t// If no sessionDir provided, derive from file's parent directory\n\t\tconst dir = sessionDir ?? resolve(path, \"..\");\n\t\treturn new SessionManager(cwd, dir, path, true);\n\t}\n\n\t/**\n\t * Continue the most recent session, or create new if none.\n\t * @param cwd Working directory\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst mostRecent = findMostRecentSession(dir);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, dir, mostRecent, true);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, null, true);\n\t}\n\n\t/** Create an in-memory session (no file persistence) */\n\tstatic inMemory(cwd: string = process.cwd()): SessionManager {\n\t\treturn new SessionManager(cwd, \"\", null, false);\n\t}\n\n\t/**\n\t * List all sessions.\n\t * @param cwd Working directory (used to compute default session directory)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic list(cwd: string, sessionDir?: string): SessionInfo[] {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst sessions: SessionInfo[] = [];\n\n\t\ttry {\n\t\t\tconst files = readdirSync(dir)\n\t\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t\t.map((f) => join(dir, f));\n\n\t\t\tfor (const file of files) {\n\t\t\t\ttry {\n\t\t\t\t\tconst content = readFileSync(file, \"utf8\");\n\t\t\t\t\tconst lines = content.trim().split(\"\\n\");\n\t\t\t\t\tif (lines.length === 0) continue;\n\n\t\t\t\t\t// Check first line for valid session header\n\t\t\t\t\tlet header: { type: string; id: string; timestamp: string } | null = null;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst first = JSON.parse(lines[0]);\n\t\t\t\t\t\tif (first.type === \"session\" && first.id) {\n\t\t\t\t\t\t\theader = first;\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Not valid JSON\n\t\t\t\t\t}\n\t\t\t\t\tif (!header) continue;\n\n\t\t\t\t\tconst stats = statSync(file);\n\t\t\t\t\tlet messageCount = 0;\n\t\t\t\t\tlet firstMessage = \"\";\n\t\t\t\t\tconst allMessages: string[] = [];\n\n\t\t\t\t\tfor (let i = 1; i < lines.length; i++) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst entry = JSON.parse(lines[i]);\n\n\t\t\t\t\t\t\tif (entry.type === \"message\") {\n\t\t\t\t\t\t\t\tmessageCount++;\n\n\t\t\t\t\t\t\t\tif (entry.message.role === \"user\" || entry.message.role === \"assistant\") {\n\t\t\t\t\t\t\t\t\tconst textContent = entry.message.content\n\t\t\t\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t\t\t\t.join(\" \");\n\n\t\t\t\t\t\t\t\t\tif (textContent) {\n\t\t\t\t\t\t\t\t\t\tallMessages.push(textContent);\n\n\t\t\t\t\t\t\t\t\t\tif (!firstMessage && entry.message.role === \"user\") {\n\t\t\t\t\t\t\t\t\t\t\tfirstMessage = textContent;\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t// Skip malformed lines\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tsessions.push({\n\t\t\t\t\t\tpath: file,\n\t\t\t\t\t\tid: header.id,\n\t\t\t\t\t\tcreated: new Date(header.timestamp),\n\t\t\t\t\t\tmodified: stats.mtime,\n\t\t\t\t\t\tmessageCount,\n\t\t\t\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\t\t\t\tallMessagesText: allMessages.join(\" \"),\n\t\t\t\t\t});\n\t\t\t\t} catch {\n\t\t\t\t\t// Skip files that can't be read\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t} catch {\n\t\t\t// Return empty list on error\n\t\t}\n\n\t\treturn sessions;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/core/session-manager.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AgentMessage } from \"@mariozechner/pi-agent-core\";\nimport type { ImageContent, Message, TextContent } from \"@mariozechner/pi-ai\";\nimport { randomUUID } from \"crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadFileSync,\n\treadSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { join, resolve } from \"path\";\nimport { getAgentDir as getDefaultAgentDir } from \"../config.js\";\nimport {\n\ttype BashExecutionMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateHookMessage,\n\ttype HookMessage,\n} from \"./messages.js\";\n\nexport const CURRENT_SESSION_VERSION = 2;\n\nexport interface SessionHeader {\n\ttype: \"session\";\n\tversion?: number; // v1 sessions don't have this\n\tid: string;\n\ttimestamp: string;\n\tcwd: string;\n\tparentSession?: string;\n}\n\nexport interface NewSessionOptions {\n\tparentSession?: string;\n}\n\nexport interface SessionEntryBase {\n\ttype: string;\n\tid: string;\n\tparentId: string | null;\n\ttimestamp: string;\n}\n\nexport interface SessionMessageEntry extends SessionEntryBase {\n\ttype: \"message\";\n\tmessage: AgentMessage;\n}\n\nexport interface ThinkingLevelChangeEntry extends SessionEntryBase {\n\ttype: \"thinking_level_change\";\n\tthinkingLevel: string;\n}\n\nexport interface ModelChangeEntry extends SessionEntryBase {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n}\n\nexport interface CompactionEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"compaction\";\n\tsummary: string;\n\tfirstKeptEntryId: string;\n\ttokensBefore: number;\n\t/** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */\n\tdetails?: T;\n\t/** True if generated by a hook, undefined/false if pi-generated (backward compatible) */\n\tfromHook?: boolean;\n}\n\nexport interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"branch_summary\";\n\tfromId: string;\n\tsummary: string;\n\t/** Hook-specific data (not sent to LLM) */\n\tdetails?: T;\n\t/** True if generated by a hook, false if pi-generated */\n\tfromHook?: boolean;\n}\n\n/**\n * Custom entry for hooks to store hook-specific data in the session.\n * Use customType to identify your hook's entries.\n *\n * Purpose: Persist hook state across session reloads. On reload, hooks can\n * scan entries for their customType and reconstruct internal state.\n *\n * Does NOT participate in LLM context (ignored by buildSessionContext).\n * For injecting content into context, see CustomMessageEntry.\n */\nexport interface CustomEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom\";\n\tcustomType: string;\n\tdata?: T;\n}\n\n/** Label entry for user-defined bookmarks/markers on entries. */\nexport interface LabelEntry extends SessionEntryBase {\n\ttype: \"label\";\n\ttargetId: string;\n\tlabel: string | undefined;\n}\n\n/**\n * Custom message entry for hooks to inject messages into LLM context.\n * Use customType to identify your hook's entries.\n *\n * Unlike CustomEntry, this DOES participate in LLM context.\n * The content is converted to a user message in buildSessionContext().\n * Use details for hook-specific metadata (not sent to LLM).\n *\n * display controls TUI rendering:\n * - false: hidden entirely\n * - true: rendered with distinct styling (different from user messages)\n */\nexport interface CustomMessageEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom_message\";\n\tcustomType: string;\n\tcontent: string | (TextContent | ImageContent)[];\n\tdetails?: T;\n\tdisplay: boolean;\n}\n\n/** Session entry - has id/parentId for tree structure (returned by \"read\" methods in SessionManager) */\nexport type SessionEntry =\n\t| SessionMessageEntry\n\t| ThinkingLevelChangeEntry\n\t| ModelChangeEntry\n\t| CompactionEntry\n\t| BranchSummaryEntry\n\t| CustomEntry\n\t| CustomMessageEntry\n\t| LabelEntry;\n\n/** Raw file entry (includes header) */\nexport type FileEntry = SessionHeader | SessionEntry;\n\n/** Tree node for getTree() - defensive copy of session structure */\nexport interface SessionTreeNode {\n\tentry: SessionEntry;\n\tchildren: SessionTreeNode[];\n\t/** Resolved label for this entry, if any */\n\tlabel?: string;\n}\n\nexport interface SessionContext {\n\tmessages: AgentMessage[];\n\tthinkingLevel: string;\n\tmodel: { provider: string; modelId: string } | null;\n}\n\nexport interface SessionInfo {\n\tpath: string;\n\tid: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\nexport type ReadonlySessionManager = Pick<\n\tSessionManager,\n\t| \"getCwd\"\n\t| \"getSessionDir\"\n\t| \"getSessionId\"\n\t| \"getSessionFile\"\n\t| \"getLeafId\"\n\t| \"getLeafEntry\"\n\t| \"getEntry\"\n\t| \"getLabel\"\n\t| \"getBranch\"\n\t| \"getHeader\"\n\t| \"getEntries\"\n\t| \"getTree\"\n>;\n\n/** Generate a unique short ID (8 hex chars, collision-checked) */\nfunction generateId(byId: { has(id: string): boolean }): string {\n\tfor (let i = 0; i < 100; i++) {\n\t\tconst id = randomUUID().slice(0, 8);\n\t\tif (!byId.has(id)) return id;\n\t}\n\t// Fallback to full UUID if somehow we have collisions\n\treturn randomUUID();\n}\n\n/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */\nfunction migrateV1ToV2(entries: FileEntry[]): void {\n\tconst ids = new Set<string>();\n\tlet prevId: string | null = null;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 2;\n\t\t\tcontinue;\n\t\t}\n\n\t\tentry.id = generateId(ids);\n\t\tentry.parentId = prevId;\n\t\tprevId = entry.id;\n\n\t\t// Convert firstKeptEntryIndex to firstKeptEntryId for compaction\n\t\tif (entry.type === \"compaction\") {\n\t\t\tconst comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };\n\t\t\tif (typeof comp.firstKeptEntryIndex === \"number\") {\n\t\t\t\tconst targetEntry = entries[comp.firstKeptEntryIndex];\n\t\t\t\tif (targetEntry && targetEntry.type !== \"session\") {\n\t\t\t\t\tcomp.firstKeptEntryId = targetEntry.id;\n\t\t\t\t}\n\t\t\t\tdelete comp.firstKeptEntryIndex;\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Add future migrations here:\n// function migrateV2ToV3(entries: FileEntry[]): void { ... }\n\n/**\n * Run all necessary migrations to bring entries to current version.\n * Mutates entries in place. Returns true if any migration was applied.\n */\nfunction migrateToCurrentVersion(entries: FileEntry[]): boolean {\n\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\tconst version = header?.version ?? 1;\n\n\tif (version >= CURRENT_SESSION_VERSION) return false;\n\n\tif (version < 2) migrateV1ToV2(entries);\n\t// if (version < 3) migrateV2ToV3(entries);\n\n\treturn true;\n}\n\n/** Exported for testing */\nexport function migrateSessionEntries(entries: FileEntry[]): void {\n\tmigrateToCurrentVersion(entries);\n}\n\n/** Exported for compaction.test.ts */\nexport function parseSessionEntries(content: string): FileEntry[] {\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nexport function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\treturn entries[i] as CompactionEntry;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Build the session context from entries using tree traversal.\n * If leafId is provided, walks from that entry to root.\n * Handles compaction and branch summaries along the path.\n */\nexport function buildSessionContext(\n\tentries: SessionEntry[],\n\tleafId?: string | null,\n\tbyId?: Map<string, SessionEntry>,\n): SessionContext {\n\t// Build uuid index if not available\n\tif (!byId) {\n\t\tbyId = new Map<string, SessionEntry>();\n\t\tfor (const entry of entries) {\n\t\t\tbyId.set(entry.id, entry);\n\t\t}\n\t}\n\n\t// Find leaf\n\tlet leaf: SessionEntry | undefined;\n\tif (leafId === null) {\n\t\t// Explicitly null - return no messages (navigated to before first entry)\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\tif (leafId) {\n\t\tleaf = byId.get(leafId);\n\t}\n\tif (!leaf) {\n\t\t// Fallback to last entry (when leafId is undefined)\n\t\tleaf = entries[entries.length - 1];\n\t}\n\n\tif (!leaf) {\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\n\t// Walk from leaf to root, collecting path\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.unshift(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\n\n\t// Extract settings and find compaction\n\tlet thinkingLevel = \"off\";\n\tlet model: { provider: string; modelId: string } | null = null;\n\tlet compaction: CompactionEntry | null = null;\n\n\tfor (const entry of path) {\n\t\tif (entry.type === \"thinking_level_change\") {\n\t\t\tthinkingLevel = entry.thinkingLevel;\n\t\t} else if (entry.type === \"model_change\") {\n\t\t\tmodel = { provider: entry.provider, modelId: entry.modelId };\n\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\tmodel = { provider: entry.message.provider, modelId: entry.message.model };\n\t\t} else if (entry.type === \"compaction\") {\n\t\t\tcompaction = entry;\n\t\t}\n\t}\n\n\t// Build messages and collect corresponding entries\n\t// When there's a compaction, we need to:\n\t// 1. Emit summary first (entry = compaction)\n\t// 2. Emit kept messages (from firstKeptEntryId up to compaction)\n\t// 3. Emit messages after compaction\n\tconst messages: AgentMessage[] = [];\n\n\tconst appendMessage = (entry: SessionEntry) => {\n\t\tif (entry.type === \"message\") {\n\t\t\tmessages.push(entry.message);\n\t\t} else if (entry.type === \"custom_message\") {\n\t\t\tmessages.push(\n\t\t\t\tcreateHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),\n\t\t\t);\n\t\t} else if (entry.type === \"branch_summary\" && entry.summary) {\n\t\t\tmessages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));\n\t\t}\n\t};\n\n\tif (compaction) {\n\t\t// Emit summary first\n\t\tmessages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));\n\n\t\t// Find compaction index in path\n\t\tconst compactionIdx = path.findIndex((e) => e.type === \"compaction\" && e.id === compaction.id);\n\n\t\t// Emit kept messages (before compaction, starting from firstKeptEntryId)\n\t\tlet foundFirstKept = false;\n\t\tfor (let i = 0; i < compactionIdx; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tif (entry.id === compaction.firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t}\n\t\t\tif (foundFirstKept) {\n\t\t\t\tappendMessage(entry);\n\t\t\t}\n\t\t}\n\n\t\t// Emit messages after compaction\n\t\tfor (let i = compactionIdx + 1; i < path.length; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tappendMessage(entry);\n\t\t}\n\t} else {\n\t\t// No compaction - emit all messages, handle branch summaries and custom messages\n\t\tfor (const entry of path) {\n\t\t\tappendMessage(entry);\n\t\t}\n\t}\n\n\treturn { messages, thinkingLevel, model };\n}\n\n/**\n * Compute the default session directory for a cwd.\n * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.\n */\nfunction getDefaultSessionDir(cwd: string): string {\n\tconst safePath = `--${cwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\tconst sessionDir = join(getDefaultAgentDir(), \"sessions\", safePath);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string): FileEntry[] {\n\tif (!existsSync(filePath)) return [];\n\n\tconst content = readFileSync(filePath, \"utf8\");\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as any).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\n}\n\nfunction isValidSessionFile(filePath: string): boolean {\n\ttry {\n\t\tconst fd = openSync(filePath, \"r\");\n\t\tconst buffer = Buffer.alloc(512);\n\t\tconst bytesRead = readSync(fd, buffer, 0, 512, 0);\n\t\tcloseSync(fd);\n\t\tconst firstLine = buffer.toString(\"utf8\", 0, bytesRead).split(\"\\n\")[0];\n\t\tif (!firstLine) return false;\n\t\tconst header = JSON.parse(firstLine);\n\t\treturn header.type === \"session\" && typeof header.id === \"string\";\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/** Exported for testing */\nexport function findMostRecentSession(sessionDir: string): string | null {\n\ttry {\n\t\tconst files = readdirSync(sessionDir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(sessionDir, f))\n\t\t\t.filter(isValidSessionFile)\n\t\t\t.map((path) => ({ path, mtime: statSync(path).mtime }))\n\t\t\t.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\n\t\treturn files[0]?.path || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Manages conversation sessions as append-only trees stored in JSONL files.\n *\n * Each session entry has an id and parentId forming a tree structure. The \"leaf\"\n * pointer tracks the current position. Appending creates a child of the current leaf.\n * Branching moves the leaf to an earlier entry, allowing new branches without\n * modifying history.\n *\n * Use buildSessionContext() to get the resolved message list for the LLM, which\n * handles compaction summaries and follows the path from root to current leaf.\n */\nexport class SessionManager {\n\tprivate sessionId: string = \"\";\n\tprivate sessionFile: string | undefined;\n\tprivate sessionDir: string;\n\tprivate cwd: string;\n\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\n\tprivate byId: Map<string, SessionEntry> = new Map();\n\tprivate labelsById: Map<string, string> = new Map();\n\tprivate leafId: string | null = null;\n\n\tprivate constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {\n\t\tthis.cwd = cwd;\n\t\tthis.sessionDir = sessionDir;\n\t\tthis.persist = persist;\n\t\tif (persist && sessionDir && !existsSync(sessionDir)) {\n\t\t\tmkdirSync(sessionDir, { recursive: true });\n\t\t}\n\n\t\tif (sessionFile) {\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t} else {\n\t\t\tthis.newSession();\n\t\t}\n\t}\n\n\t/** Switch to a different session file (used for resume and branching) */\n\tsetSessionFile(sessionFile: string): void {\n\t\tthis.sessionFile = resolve(sessionFile);\n\t\tif (existsSync(this.sessionFile)) {\n\t\t\tthis.fileEntries = loadEntriesFromFile(this.sessionFile);\n\t\t\tconst header = this.fileEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\t\tthis.sessionId = header?.id ?? randomUUID();\n\n\t\t\tif (migrateToCurrentVersion(this.fileEntries)) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t}\n\n\t\t\tthis._buildIndex();\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tthis.newSession();\n\t\t}\n\t}\n\n\tnewSession(options?: NewSessionOptions): string | undefined {\n\t\tthis.sessionId = randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: this.sessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: options?.parentSession,\n\t\t};\n\t\tthis.fileEntries = [header];\n\t\tthis.byId.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\n\n\t\t// Only generate filename if persisting and not already set (e.g., via --session flag)\n\t\tif (this.persist && !this.sessionFile) {\n\t\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\t\tthis.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);\n\t\t}\n\t\treturn this.sessionFile;\n\t}\n\n\tprivate _buildIndex(): void {\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tfor (const entry of this.fileEntries) {\n\t\t\tif (entry.type === \"session\") continue;\n\t\t\tthis.byId.set(entry.id, entry);\n\t\t\tthis.leafId = entry.id;\n\t\t\tif (entry.type === \"label\") {\n\t\t\t\tif (entry.label) {\n\t\t\t\t\tthis.labelsById.set(entry.targetId, entry.label);\n\t\t\t\t} else {\n\t\t\t\t\tthis.labelsById.delete(entry.targetId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate _rewriteFile(): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\t\tconst content = `${this.fileEntries.map((e) => JSON.stringify(e)).join(\"\\n\")}\\n`;\n\t\twriteFileSync(this.sessionFile, content);\n\t}\n\n\tisPersisted(): boolean {\n\t\treturn this.persist;\n\t}\n\n\tgetCwd(): string {\n\t\treturn this.cwd;\n\t}\n\n\tgetSessionDir(): string {\n\t\treturn this.sessionDir;\n\t}\n\n\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string | undefined {\n\t\treturn this.sessionFile;\n\t}\n\n\t_persist(entry: SessionEntry): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\n\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\tif (!hasAssistant) return;\n\n\t\tif (!this.flushed) {\n\t\t\tfor (const e of this.fileEntries) {\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(e)}\\n`);\n\t\t\t}\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t}\n\t}\n\n\tprivate _appendEntry(entry: SessionEntry): void {\n\t\tthis.fileEntries.push(entry);\n\t\tthis.byId.set(entry.id, entry);\n\t\tthis.leafId = entry.id;\n\t\tthis._persist(entry);\n\t}\n\n\t/** Append a message as child of current leaf, then advance leaf. Returns entry id.\n\t * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.\n\t * Reason: we want these to be top-level entries in the session, not message session entries,\n\t * so it is easier to find them.\n\t * These need to be appended via appendCompaction() and appendBranchSummary() methods.\n\t */\n\tappendMessage(message: Message | HookMessage | BashExecutionMessage): string {\n\t\tconst entry: SessionMessageEntry = {\n\t\t\ttype: \"message\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tmessage,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendThinkingLevelChange(thinkingLevel: string): string {\n\t\tconst entry: ThinkingLevelChangeEntry = {\n\t\t\ttype: \"thinking_level_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tthinkingLevel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendModelChange(provider: string, modelId: string): string {\n\t\tconst entry: ModelChangeEntry = {\n\t\t\ttype: \"model_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tprovider,\n\t\t\tmodelId,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCompaction<T = unknown>(\n\t\tsummary: string,\n\t\tfirstKeptEntryId: string,\n\t\ttokensBefore: number,\n\t\tdetails?: T,\n\t\tfromHook?: boolean,\n\t): string {\n\t\tconst entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tsummary,\n\t\t\tfirstKeptEntryId,\n\t\t\ttokensBefore,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCustomEntry(customType: string, data?: unknown): string {\n\t\tconst entry: CustomEntry = {\n\t\t\ttype: \"custom\",\n\t\t\tcustomType,\n\t\t\tdata,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Append a custom message entry (for hooks) that participates in LLM context.\n\t * @param customType Hook identifier for filtering on reload\n\t * @param content Message content (string or TextContent/ImageContent array)\n\t * @param display Whether to show in TUI (true = styled display, false = hidden)\n\t * @param details Optional hook-specific metadata (not sent to LLM)\n\t * @returns Entry id\n\t */\n\tappendCustomMessageEntry<T = unknown>(\n\t\tcustomType: string,\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\tdisplay: boolean,\n\t\tdetails?: T,\n\t): string {\n\t\tconst entry: CustomMessageEntry<T> = {\n\t\t\ttype: \"custom_message\",\n\t\t\tcustomType,\n\t\t\tcontent,\n\t\t\tdisplay,\n\t\t\tdetails,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t// =========================================================================\n\t// Tree Traversal\n\t// =========================================================================\n\n\tgetLeafId(): string | null {\n\t\treturn this.leafId;\n\t}\n\n\tgetLeafEntry(): SessionEntry | undefined {\n\t\treturn this.leafId ? this.byId.get(this.leafId) : undefined;\n\t}\n\n\tgetEntry(id: string): SessionEntry | undefined {\n\t\treturn this.byId.get(id);\n\t}\n\n\t/**\n\t * Get all direct children of an entry.\n\t */\n\tgetChildren(parentId: string): SessionEntry[] {\n\t\tconst children: SessionEntry[] = [];\n\t\tfor (const entry of this.byId.values()) {\n\t\t\tif (entry.parentId === parentId) {\n\t\t\t\tchildren.push(entry);\n\t\t\t}\n\t\t}\n\t\treturn children;\n\t}\n\n\t/**\n\t * Get the label for an entry, if any.\n\t */\n\tgetLabel(id: string): string | undefined {\n\t\treturn this.labelsById.get(id);\n\t}\n\n\t/**\n\t * Set or clear a label on an entry.\n\t * Labels are user-defined markers for bookmarking/navigation.\n\t * Pass undefined or empty string to clear the label.\n\t */\n\tappendLabelChange(targetId: string, label: string | undefined): string {\n\t\tif (!this.byId.has(targetId)) {\n\t\t\tthrow new Error(`Entry ${targetId} not found`);\n\t\t}\n\t\tconst entry: LabelEntry = {\n\t\t\ttype: \"label\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttargetId,\n\t\t\tlabel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tif (label) {\n\t\t\tthis.labelsById.set(targetId, label);\n\t\t} else {\n\t\t\tthis.labelsById.delete(targetId);\n\t\t}\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Walk from entry to root, returning all entries in path order.\n\t * Includes all entry types (messages, compaction, model changes, etc.).\n\t * Use buildSessionContext() to get the resolved messages for the LLM.\n\t */\n\tgetBranch(fromId?: string): SessionEntry[] {\n\t\tconst path: SessionEntry[] = [];\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tpath.unshift(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn path;\n\t}\n\n\t/**\n\t * Build the session context (what gets sent to the LLM).\n\t * Uses tree traversal from current leaf.\n\t */\n\tbuildSessionContext(): SessionContext {\n\t\treturn buildSessionContext(this.getEntries(), this.leafId, this.byId);\n\t}\n\n\t/**\n\t * Get session header.\n\t */\n\tgetHeader(): SessionHeader | null {\n\t\tconst h = this.fileEntries.find((e) => e.type === \"session\");\n\t\treturn h ? (h as SessionHeader) : null;\n\t}\n\n\t/**\n\t * Get all session entries (excludes header). Returns a shallow copy.\n\t * The session is append-only: use appendXXX() to add entries, branch() to\n\t * change the leaf pointer. Entries cannot be modified or deleted.\n\t */\n\tgetEntries(): SessionEntry[] {\n\t\treturn this.fileEntries.filter((e): e is SessionEntry => e.type !== \"session\");\n\t}\n\n\t/**\n\t * Get the session as a tree structure. Returns a shallow defensive copy of all entries.\n\t * A well-formed session has exactly one root (first entry with parentId === null).\n\t * Orphaned entries (broken parent chain) are also returned as roots.\n\t */\n\tgetTree(): SessionTreeNode[] {\n\t\tconst entries = this.getEntries();\n\t\tconst nodeMap = new Map<string, SessionTreeNode>();\n\t\tconst roots: SessionTreeNode[] = [];\n\n\t\t// Create nodes with resolved labels\n\t\tfor (const entry of entries) {\n\t\t\tconst label = this.labelsById.get(entry.id);\n\t\t\tnodeMap.set(entry.id, { entry, children: [], label });\n\t\t}\n\n\t\t// Build tree\n\t\tfor (const entry of entries) {\n\t\t\tconst node = nodeMap.get(entry.id)!;\n\t\t\tif (entry.parentId === null || entry.parentId === entry.id) {\n\t\t\t\troots.push(node);\n\t\t\t} else {\n\t\t\t\tconst parent = nodeMap.get(entry.parentId);\n\t\t\t\tif (parent) {\n\t\t\t\t\tparent.children.push(node);\n\t\t\t\t} else {\n\t\t\t\t\t// Orphan - treat as root\n\t\t\t\t\troots.push(node);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Sort children by timestamp (oldest first, newest at bottom)\n\t\t// Use iterative approach to avoid stack overflow on deep trees\n\t\tconst stack: SessionTreeNode[] = [...roots];\n\t\twhile (stack.length > 0) {\n\t\t\tconst node = stack.pop()!;\n\t\t\tnode.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());\n\t\t\tstack.push(...node.children);\n\t\t}\n\n\t\treturn roots;\n\t}\n\n\t// =========================================================================\n\t// Branching\n\t// =========================================================================\n\n\t/**\n\t * Start a new branch from an earlier entry.\n\t * Moves the leaf pointer to the specified entry. The next appendXXX() call\n\t * will create a child of that entry, forming a new branch. Existing entries\n\t * are not modified or deleted.\n\t */\n\tbranch(branchFromId: string): void {\n\t\tif (!this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t}\n\n\t/**\n\t * Reset the leaf pointer to null (before any entries).\n\t * The next appendXXX() call will create a new root entry (parentId = null).\n\t * Use this when navigating to re-edit the first user message.\n\t */\n\tresetLeaf(): void {\n\t\tthis.leafId = null;\n\t}\n\n\t/**\n\t * Start a new branch with a summary of the abandoned path.\n\t * Same as branch(), but also appends a branch_summary entry that captures\n\t * context from the abandoned conversation path.\n\t */\n\tbranchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {\n\t\tif (branchFromId !== null && !this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t\tconst entry: BranchSummaryEntry = {\n\t\t\ttype: \"branch_summary\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: branchFromId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tfromId: branchFromId ?? \"root\",\n\t\t\tsummary,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Create a new session file containing only the path from root to the specified leaf.\n\t * Useful for extracting a single conversation path from a branched session.\n\t * Returns the new session file path, or undefined if not persisting.\n\t */\n\tcreateBranchedSession(leafId: string): string | undefined {\n\t\tconst path = this.getBranch(leafId);\n\t\tif (path.length === 0) {\n\t\t\tthrow new Error(`Entry ${leafId} not found`);\n\t\t}\n\n\t\t// Filter out LabelEntry from path - we'll recreate them from the resolved map\n\t\tconst pathWithoutLabels = path.filter((e) => e.type !== \"label\");\n\n\t\tconst newSessionId = randomUUID();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: this.persist ? this.sessionFile : undefined,\n\t\t};\n\n\t\t// Collect labels for entries in the path\n\t\tconst pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));\n\t\tconst labelsToWrite: Array<{ targetId: string; label: string }> = [];\n\t\tfor (const [targetId, label] of this.labelsById) {\n\t\t\tif (pathEntryIds.has(targetId)) {\n\t\t\t\tlabelsToWrite.push({ targetId, label });\n\t\t\t}\n\t\t}\n\n\t\tif (this.persist) {\n\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(header)}\\n`);\n\t\t\tfor (const entry of pathWithoutLabels) {\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t\t// Write fresh label entries at the end\n\t\t\tconst lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\t\tlet parentId = lastEntryId;\n\t\t\tconst labelEntries: LabelEntry[] = [];\n\t\t\tfor (const { targetId, label } of labelsToWrite) {\n\t\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\t\ttype: \"label\",\n\t\t\t\t\tid: generateId(new Set(pathEntryIds)),\n\t\t\t\t\tparentId,\n\t\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\t\ttargetId,\n\t\t\t\t\tlabel,\n\t\t\t\t};\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\\n`);\n\t\t\t\tpathEntryIds.add(labelEntry.id);\n\t\t\t\tlabelEntries.push(labelEntry);\n\t\t\t\tparentId = labelEntry.id;\n\t\t\t}\n\t\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\t\tthis.sessionId = newSessionId;\n\t\t\tthis._buildIndex();\n\t\t\treturn newSessionFile;\n\t\t}\n\n\t\t// In-memory mode: replace current session with the path + labels\n\t\tconst labelEntries: LabelEntry[] = [];\n\t\tlet parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\tfor (const { targetId, label } of labelsToWrite) {\n\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\ttype: \"label\",\n\t\t\t\tid: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),\n\t\t\t\tparentId,\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t\ttargetId,\n\t\t\t\tlabel,\n\t\t\t};\n\t\t\tlabelEntries.push(labelEntry);\n\t\t\tparentId = labelEntry.id;\n\t\t}\n\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\tthis.sessionId = newSessionId;\n\t\tthis._buildIndex();\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Create a new session.\n\t * @param cwd Working directory (stored in session header)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\n\t * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.\n\t */\n\tstatic open(path: string, sessionDir?: string): SessionManager {\n\t\t// Extract cwd from session header if possible, otherwise use process.cwd()\n\t\tconst entries = loadEntriesFromFile(path);\n\t\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tconst cwd = header?.cwd ?? process.cwd();\n\t\t// If no sessionDir provided, derive from file's parent directory\n\t\tconst dir = sessionDir ?? resolve(path, \"..\");\n\t\treturn new SessionManager(cwd, dir, path, true);\n\t}\n\n\t/**\n\t * Continue the most recent session, or create new if none.\n\t * @param cwd Working directory\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst mostRecent = findMostRecentSession(dir);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, dir, mostRecent, true);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/** Create an in-memory session (no file persistence) */\n\tstatic inMemory(cwd: string = process.cwd()): SessionManager {\n\t\treturn new SessionManager(cwd, \"\", undefined, false);\n\t}\n\n\t/**\n\t * List all sessions.\n\t * @param cwd Working directory (used to compute default session directory)\n\t * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic list(cwd: string, sessionDir?: string): SessionInfo[] {\n\t\tconst dir = sessionDir ?? getDefaultSessionDir(cwd);\n\t\tconst sessions: SessionInfo[] = [];\n\n\t\ttry {\n\t\t\tconst files = readdirSync(dir)\n\t\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t\t.map((f) => join(dir, f));\n\n\t\t\tfor (const file of files) {\n\t\t\t\ttry {\n\t\t\t\t\tconst content = readFileSync(file, \"utf8\");\n\t\t\t\t\tconst lines = content.trim().split(\"\\n\");\n\t\t\t\t\tif (lines.length === 0) continue;\n\n\t\t\t\t\t// Check first line for valid session header\n\t\t\t\t\tlet header: { type: string; id: string; timestamp: string } | null = null;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst first = JSON.parse(lines[0]);\n\t\t\t\t\t\tif (first.type === \"session\" && first.id) {\n\t\t\t\t\t\t\theader = first;\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Not valid JSON\n\t\t\t\t\t}\n\t\t\t\t\tif (!header) continue;\n\n\t\t\t\t\tconst stats = statSync(file);\n\t\t\t\t\tlet messageCount = 0;\n\t\t\t\t\tlet firstMessage = \"\";\n\t\t\t\t\tconst allMessages: string[] = [];\n\n\t\t\t\t\tfor (let i = 1; i < lines.length; i++) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst entry = JSON.parse(lines[i]);\n\n\t\t\t\t\t\t\tif (entry.type === \"message\") {\n\t\t\t\t\t\t\t\tmessageCount++;\n\n\t\t\t\t\t\t\t\tif (entry.message.role === \"user\" || entry.message.role === \"assistant\") {\n\t\t\t\t\t\t\t\t\tconst textContent = entry.message.content\n\t\t\t\t\t\t\t\t\t\t.filter((c: any) => c.type === \"text\")\n\t\t\t\t\t\t\t\t\t\t.map((c: any) => c.text)\n\t\t\t\t\t\t\t\t\t\t.join(\" \");\n\n\t\t\t\t\t\t\t\t\tif (textContent) {\n\t\t\t\t\t\t\t\t\t\tallMessages.push(textContent);\n\n\t\t\t\t\t\t\t\t\t\tif (!firstMessage && entry.message.role === \"user\") {\n\t\t\t\t\t\t\t\t\t\t\tfirstMessage = textContent;\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t// Skip malformed lines\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tsessions.push({\n\t\t\t\t\t\tpath: file,\n\t\t\t\t\t\tid: header.id,\n\t\t\t\t\t\tcreated: new Date(header.timestamp),\n\t\t\t\t\t\tmodified: stats.mtime,\n\t\t\t\t\t\tmessageCount,\n\t\t\t\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\t\t\t\tallMessagesText: allMessages.join(\" \"),\n\t\t\t\t\t});\n\t\t\t\t} catch {\n\t\t\t\t\t// Skip files that can't be read\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t} catch {\n\t\t\t// Return empty list on error\n\t\t}\n\n\t\treturn sessions;\n\t}\n}\n"]}
|