@meowlynxsea/koi 0.1.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/LICENSE +34 -0
- package/NOTICE +35 -0
- package/README.md +15 -0
- package/bin/koi +12 -0
- package/dist/highlights-eq9cgrbb.scm +604 -0
- package/dist/highlights-ghv9g403.scm +205 -0
- package/dist/highlights-hk7bwhj4.scm +284 -0
- package/dist/highlights-r812a2qc.scm +150 -0
- package/dist/highlights-x6tmsnaa.scm +115 -0
- package/dist/injections-73j83es3.scm +27 -0
- package/dist/main.js +489918 -0
- package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/package.json +51 -0
- package/src/agent/check-permissions.ts +239 -0
- package/src/agent/hooks/message-utils.ts +305 -0
- package/src/agent/hooks/types.ts +32 -0
- package/src/agent/hooks.ts +1560 -0
- package/src/agent/mode.ts +163 -0
- package/src/agent/monitor-registry.ts +308 -0
- package/src/agent/permission-ui.ts +71 -0
- package/src/agent/plan-ui.ts +74 -0
- package/src/agent/question-ui.ts +58 -0
- package/src/agent/session-fork.ts +299 -0
- package/src/agent/session-snapshots.ts +216 -0
- package/src/agent/session-store.ts +649 -0
- package/src/agent/session-tasks.ts +305 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +176 -0
- package/src/agent/subagent.ts +194 -0
- package/src/agent/tool-orchestration.ts +55 -0
- package/src/agent/tools.ts +8 -0
- package/src/cli/args.ts +6 -0
- package/src/cli/commands.ts +5 -0
- package/src/commands/skills/index.ts +23 -0
- package/src/config/models.ts +6 -0
- package/src/config/settings.ts +392 -0
- package/src/main.tsx +64 -0
- package/src/services/mcp/client.ts +194 -0
- package/src/services/mcp/config.ts +232 -0
- package/src/services/mcp/connection-manager.ts +258 -0
- package/src/services/mcp/index.ts +80 -0
- package/src/services/mcp/mcp-commands.ts +114 -0
- package/src/services/mcp/stdio-transport.ts +246 -0
- package/src/services/mcp/types.ts +155 -0
- package/src/skills/SkillsMenu.tsx +370 -0
- package/src/skills/bundled/batch.ts +106 -0
- package/src/skills/bundled/debug.ts +86 -0
- package/src/skills/bundled/loremIpsum.ts +101 -0
- package/src/skills/bundled/remember.ts +97 -0
- package/src/skills/bundled/simplify.ts +100 -0
- package/src/skills/bundled/skillify.ts +123 -0
- package/src/skills/bundled/stuck.ts +101 -0
- package/src/skills/bundled/updateConfig.ts +228 -0
- package/src/skills/bundled.ts +46 -0
- package/src/skills/frontmatter.ts +179 -0
- package/src/skills/index.ts +87 -0
- package/src/skills/invoke.ts +231 -0
- package/src/skills/loader.ts +710 -0
- package/src/skills/substitution.ts +169 -0
- package/src/skills/types.ts +201 -0
- package/src/tools/agent.ts +143 -0
- package/src/tools/ask-user-question.ts +46 -0
- package/src/tools/bash.ts +148 -0
- package/src/tools/edit.ts +164 -0
- package/src/tools/glob.ts +102 -0
- package/src/tools/grep.ts +248 -0
- package/src/tools/index.ts +73 -0
- package/src/tools/list-mcp-resources.ts +74 -0
- package/src/tools/ls.ts +85 -0
- package/src/tools/mcp.ts +76 -0
- package/src/tools/monitor.ts +159 -0
- package/src/tools/plan-mode.ts +134 -0
- package/src/tools/read-mcp-resource.ts +79 -0
- package/src/tools/read.ts +137 -0
- package/src/tools/skill.ts +176 -0
- package/src/tools/task.ts +349 -0
- package/src/tools/types.ts +52 -0
- package/src/tools/webfetch-domains.ts +239 -0
- package/src/tools/webfetch.ts +533 -0
- package/src/tools/write.ts +101 -0
- package/src/tui/app.tsx +1178 -0
- package/src/tui/components/chat-panel.tsx +1071 -0
- package/src/tui/components/command-panel.tsx +261 -0
- package/src/tui/components/confirm-modal.tsx +135 -0
- package/src/tui/components/connect-modal.tsx +435 -0
- package/src/tui/components/connecting-modal.tsx +167 -0
- package/src/tui/components/edit-pending-modal.tsx +103 -0
- package/src/tui/components/exit-modal.tsx +131 -0
- package/src/tui/components/fork-modal.tsx +377 -0
- package/src/tui/components/image-preview-modal.tsx +141 -0
- package/src/tui/components/image-utils.ts +128 -0
- package/src/tui/components/info-bar.tsx +103 -0
- package/src/tui/components/input-box.tsx +352 -0
- package/src/tui/components/mcp/MCPSettings.tsx +386 -0
- package/src/tui/components/mcp/index.ts +7 -0
- package/src/tui/components/model-modal.tsx +310 -0
- package/src/tui/components/pending-area.tsx +88 -0
- package/src/tui/components/rename-modal.tsx +119 -0
- package/src/tui/components/session-modal.tsx +233 -0
- package/src/tui/components/side-bar.tsx +349 -0
- package/src/tui/components/tool-output.ts +6 -0
- package/src/tui/hooks/user-prompt-history.ts +114 -0
- package/src/tui/theme.ts +63 -0
- package/src/types/commands.ts +80 -0
- package/src/types/cross-spawn.d.ts +24 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Fork Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages fork metadata for session branching. Tracks:
|
|
5
|
+
* - Fork relationships between sessions
|
|
6
|
+
* - Task snapshots at fork time
|
|
7
|
+
* - Agent mode state at fork time
|
|
8
|
+
* - Pending plan content at fork time
|
|
9
|
+
*
|
|
10
|
+
* All fork metadata is persisted to ~/.config/koi/sessions/forks/<sessionId>.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import type { Task } from "./session-tasks.js";
|
|
17
|
+
import type { AgentMode } from "./mode.js";
|
|
18
|
+
|
|
19
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "koi");
|
|
20
|
+
const KOI_SESSIONS_DIR = path.join(CONFIG_DIR, "sessions");
|
|
21
|
+
const FORK_METADATA_DIR = path.join(KOI_SESSIONS_DIR, "forks");
|
|
22
|
+
|
|
23
|
+
export interface ForkMetadata {
|
|
24
|
+
/** ID of the forked session */
|
|
25
|
+
forkId: string;
|
|
26
|
+
/** ID of the source session this was forked from */
|
|
27
|
+
sourceSessionId: string;
|
|
28
|
+
/** Branch ID in the source session at the fork point */
|
|
29
|
+
sourceBranchId: string;
|
|
30
|
+
/** Entry ID where the fork occurred */
|
|
31
|
+
forkPoint: string;
|
|
32
|
+
/** Timestamp when the fork was created */
|
|
33
|
+
forkedAt: number;
|
|
34
|
+
/** Snapshot of tasks at fork time (before fork) */
|
|
35
|
+
tasksSnapshot: Task[];
|
|
36
|
+
/** Agent mode state at fork time */
|
|
37
|
+
agentMode: AgentMode;
|
|
38
|
+
/** Active tool names at fork time */
|
|
39
|
+
activeTools: string[];
|
|
40
|
+
/** Pending plan text at fork time (if in plan mode with pending content) */
|
|
41
|
+
pendingPlanText: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ForkTreeNode {
|
|
45
|
+
sessionId: string;
|
|
46
|
+
forkId: string;
|
|
47
|
+
title: string;
|
|
48
|
+
forkedAt: number;
|
|
49
|
+
children: ForkTreeNode[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* File System Helpers
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
function ensureDir(dir: string): void {
|
|
57
|
+
if (!fs.existsSync(dir)) {
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getForkMetadataPath(sessionId: string): string {
|
|
63
|
+
return path.join(FORK_METADATA_DIR, `${sessionId}.json`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeReadFile<T>(filePath: string, parser: (raw: string) => T): T | null {
|
|
67
|
+
try {
|
|
68
|
+
if (!fs.existsSync(filePath)) return null;
|
|
69
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
70
|
+
return parser(raw);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeWriteFile(filePath: string, data: string): void {
|
|
77
|
+
try {
|
|
78
|
+
ensureDir(path.dirname(filePath));
|
|
79
|
+
fs.writeFileSync(filePath, data, { mode: 0o600 });
|
|
80
|
+
} catch {
|
|
81
|
+
// Silently ignore write errors
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeDeleteFile(filePath: string): void {
|
|
86
|
+
try {
|
|
87
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ForkManager
|
|
95
|
+
*
|
|
96
|
+
* Singleton class managing all fork metadata for sessions.
|
|
97
|
+
* Provides methods to create, save, load, and query fork relationships.
|
|
98
|
+
*/
|
|
99
|
+
export class ForkManager {
|
|
100
|
+
private cache = new Map<string, ForkMetadata | null>();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Save fork metadata for a session.
|
|
104
|
+
* Also updates the parent session's childForks list.
|
|
105
|
+
*/
|
|
106
|
+
saveForkMetadata(forkId: string, metadata: ForkMetadata): void {
|
|
107
|
+
this.cache.set(forkId, metadata);
|
|
108
|
+
safeWriteFile(
|
|
109
|
+
getForkMetadataPath(forkId),
|
|
110
|
+
JSON.stringify(metadata, null, 2) + "\n"
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Update parent session's child forks
|
|
114
|
+
const parentMeta = this.loadForkMetadata(metadata.sourceSessionId);
|
|
115
|
+
if (parentMeta) {
|
|
116
|
+
// Parent is a forked session, update its child forks tracking
|
|
117
|
+
const childForksPath = path.join(
|
|
118
|
+
KOI_SESSIONS_DIR,
|
|
119
|
+
metadata.sourceSessionId,
|
|
120
|
+
"child-forks.json"
|
|
121
|
+
);
|
|
122
|
+
const existingChildForks: string[] = safeReadFile<string[]>(childForksPath, (raw) => JSON.parse(raw) as string[]) ?? [];
|
|
123
|
+
if (!existingChildForks.includes(forkId)) {
|
|
124
|
+
safeWriteFile(childForksPath, JSON.stringify([...existingChildForks, forkId], null, 2) + "\n");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Load fork metadata for a session from disk.
|
|
131
|
+
*/
|
|
132
|
+
loadForkMetadata(sessionId: string): ForkMetadata | null {
|
|
133
|
+
// Check cache first
|
|
134
|
+
if (this.cache.has(sessionId)) {
|
|
135
|
+
return this.cache.get(sessionId) ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const metadata = safeReadFile<ForkMetadata>(
|
|
139
|
+
getForkMetadataPath(sessionId),
|
|
140
|
+
(raw) => JSON.parse(raw) as ForkMetadata
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
this.cache.set(sessionId, metadata);
|
|
144
|
+
return metadata;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get the fork metadata for a session, returning null if not a fork.
|
|
149
|
+
*/
|
|
150
|
+
getForkMetadata(sessionId: string): ForkMetadata | null {
|
|
151
|
+
return this.loadForkMetadata(sessionId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a session is a fork.
|
|
156
|
+
*/
|
|
157
|
+
isFork(sessionId: string): boolean {
|
|
158
|
+
const meta = this.loadForkMetadata(sessionId);
|
|
159
|
+
return meta !== null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get the source session ID for a fork.
|
|
164
|
+
*/
|
|
165
|
+
getSourceSessionId(sessionId: string): string | null {
|
|
166
|
+
const meta = this.loadForkMetadata(sessionId);
|
|
167
|
+
return meta?.sourceSessionId ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get all child fork IDs for a session.
|
|
172
|
+
*/
|
|
173
|
+
getChildForks(sessionId: string): string[] {
|
|
174
|
+
const childForksPath = path.join(KOI_SESSIONS_DIR, sessionId, "child-forks.json");
|
|
175
|
+
return safeReadFile<string[]>(childForksPath, (raw) => JSON.parse(raw) as string[]) ?? [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Calculate the fork depth of a session (0 for original, 1+ for forks).
|
|
180
|
+
*/
|
|
181
|
+
getForkDepth(sessionId: string): number {
|
|
182
|
+
let depth = 0;
|
|
183
|
+
let currentId: string | null = sessionId;
|
|
184
|
+
const visited = new Set<string>();
|
|
185
|
+
|
|
186
|
+
while (currentId) {
|
|
187
|
+
if (visited.has(currentId)) break;
|
|
188
|
+
visited.add(currentId);
|
|
189
|
+
|
|
190
|
+
const meta = this.loadForkMetadata(currentId);
|
|
191
|
+
if (!meta) break;
|
|
192
|
+
if (meta.sourceSessionId === currentId) break; // self-referential
|
|
193
|
+
|
|
194
|
+
depth++;
|
|
195
|
+
currentId = meta.sourceSessionId;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return depth;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build the full fork tree starting from a session.
|
|
203
|
+
*/
|
|
204
|
+
getForkTree(sessionId: string, title: string = "Session", visited?: Set<string>): ForkTreeNode {
|
|
205
|
+
if (!visited) visited = new Set();
|
|
206
|
+
if (visited.has(sessionId)) {
|
|
207
|
+
return {
|
|
208
|
+
sessionId,
|
|
209
|
+
forkId: sessionId,
|
|
210
|
+
title: "(cycle)",
|
|
211
|
+
forkedAt: Date.now(),
|
|
212
|
+
children: [],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
visited.add(sessionId);
|
|
216
|
+
|
|
217
|
+
const meta = this.loadForkMetadata(sessionId);
|
|
218
|
+
const forkedAt = meta?.forkedAt ?? Date.now();
|
|
219
|
+
|
|
220
|
+
const node: ForkTreeNode = {
|
|
221
|
+
sessionId,
|
|
222
|
+
forkId: meta?.forkId ?? sessionId,
|
|
223
|
+
title,
|
|
224
|
+
forkedAt,
|
|
225
|
+
children: [],
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Recursively build children
|
|
229
|
+
const childForks = this.getChildForks(sessionId);
|
|
230
|
+
for (const childId of childForks) {
|
|
231
|
+
const childMeta = this.loadForkMetadata(childId);
|
|
232
|
+
const childTitle = childMeta?.forkPoint
|
|
233
|
+
? `Fork at ${childMeta.forkPoint.slice(0, 8)}...`
|
|
234
|
+
: "Fork";
|
|
235
|
+
node.children.push(this.getForkTree(childId, childTitle, visited));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return node;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get all fork metadata for sessions that originated from a given session.
|
|
243
|
+
*/
|
|
244
|
+
getForkHistory(sessionId: string, visited?: Set<string>): ForkMetadata[] {
|
|
245
|
+
if (!visited) visited = new Set();
|
|
246
|
+
if (visited.has(sessionId)) return [];
|
|
247
|
+
visited.add(sessionId);
|
|
248
|
+
|
|
249
|
+
const results: ForkMetadata[] = [];
|
|
250
|
+
const childForks = this.getChildForks(sessionId);
|
|
251
|
+
|
|
252
|
+
for (const childId of childForks) {
|
|
253
|
+
const meta = this.loadForkMetadata(childId);
|
|
254
|
+
if (meta) {
|
|
255
|
+
results.push(meta);
|
|
256
|
+
// Recursively get grandchildren
|
|
257
|
+
results.push(...this.getForkHistory(childId, visited));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return results;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Delete fork metadata for a session.
|
|
266
|
+
* Also removes the session ID from parent session's child-forks list.
|
|
267
|
+
*/
|
|
268
|
+
deleteForkMetadata(sessionId: string): void {
|
|
269
|
+
const meta = this.loadForkMetadata(sessionId);
|
|
270
|
+
if (meta) {
|
|
271
|
+
// Remove from parent's child-forks
|
|
272
|
+
const parentChildForksPath = path.join(
|
|
273
|
+
KOI_SESSIONS_DIR,
|
|
274
|
+
meta.sourceSessionId,
|
|
275
|
+
"child-forks.json"
|
|
276
|
+
);
|
|
277
|
+
const existingChildForks: string[] = safeReadFile<string[]>(parentChildForksPath, (raw) => JSON.parse(raw) as string[]) ?? [];
|
|
278
|
+
const updatedChildForks = existingChildForks.filter(id => id !== sessionId);
|
|
279
|
+
if (updatedChildForks.length > 0) {
|
|
280
|
+
safeWriteFile(parentChildForksPath, JSON.stringify(updatedChildForks, null, 2) + "\n");
|
|
281
|
+
} else {
|
|
282
|
+
safeDeleteFile(parentChildForksPath);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.cache.delete(sessionId);
|
|
287
|
+
safeDeleteFile(getForkMetadataPath(sessionId));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Clear the in-memory cache.
|
|
292
|
+
*/
|
|
293
|
+
clearCache(): void {
|
|
294
|
+
this.cache.clear();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Global singleton instance
|
|
299
|
+
export const forkManager = new ForkManager();
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Snapshots
|
|
3
|
+
*
|
|
4
|
+
* Records per-entry snapshots of Koi state (tasks, plan, agent mode) using
|
|
5
|
+
* Pi's CustomEntry mechanism. Each snapshot is stored as a `custom` entry
|
|
6
|
+
* with `customType: "koi:snapshot"` in the session tree.
|
|
7
|
+
*
|
|
8
|
+
* Because `appendCustomEntry` appends the snapshot as a child of the current
|
|
9
|
+
* leaf and then advances leaf, the snapshot sits **after** the turn it
|
|
10
|
+
* represents. When forking to entry X we must therefore look **forward**
|
|
11
|
+
* from X (in X's subtree) to find the snapshot that captured the state
|
|
12
|
+
* right after X's turn completed. When loading a session we look
|
|
13
|
+
* **backward** from the current leaf toward root.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AgentSession, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import type { Task } from "./session-tasks.js";
|
|
18
|
+
import type { SessionTaskManager } from "./session-tasks.js";
|
|
19
|
+
import type { AgentMode } from "./mode.js";
|
|
20
|
+
import { setCurrentPlanText } from "./plan-ui.js";
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
|
|
23
|
+
export interface KoiSnapshotData {
|
|
24
|
+
tasks: Task[];
|
|
25
|
+
planText: string | null;
|
|
26
|
+
agentMode: AgentMode;
|
|
27
|
+
activeTools: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SNAPSHOT_CUSTOM_TYPE = "koi:snapshot";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Save a snapshot of the current Koi state as a CustomEntry in the Pi session.
|
|
34
|
+
* The snapshot is attached as a child of the current leaf entry, then leaf advances.
|
|
35
|
+
*/
|
|
36
|
+
export function saveSnapshot(
|
|
37
|
+
session: AgentSession,
|
|
38
|
+
data: KoiSnapshotData
|
|
39
|
+
): string {
|
|
40
|
+
// Deep clone to prevent later mutations from corrupting persisted snapshot data.
|
|
41
|
+
// Pi's appendCustomEntry does not clone the payload, so object references
|
|
42
|
+
// (e.g. Task objects in the tasks array) would be mutated by subsequent
|
|
43
|
+
// task updates, making old snapshots reflect the latest state instead of
|
|
44
|
+
// the state at the time they were saved.
|
|
45
|
+
const cloned: KoiSnapshotData = {
|
|
46
|
+
tasks: data.tasks.map((t) => ({ ...t })),
|
|
47
|
+
planText: data.planText,
|
|
48
|
+
agentMode: data.agentMode,
|
|
49
|
+
activeTools: [...data.activeTools],
|
|
50
|
+
};
|
|
51
|
+
return session.sessionManager.appendCustomEntry(SNAPSHOT_CUSTOM_TYPE, cloned);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Save a snapshot only if the state has changed since the last snapshot.
|
|
56
|
+
* Returns the new entry id, or null if nothing changed.
|
|
57
|
+
*/
|
|
58
|
+
export function saveSnapshotIfChanged(
|
|
59
|
+
session: AgentSession,
|
|
60
|
+
data: KoiSnapshotData
|
|
61
|
+
): string | null {
|
|
62
|
+
const leafId = session.sessionManager.getLeafId();
|
|
63
|
+
if (!leafId) return null;
|
|
64
|
+
|
|
65
|
+
const last = findSnapshotBeforeEntry(session, leafId);
|
|
66
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] saveSnapshotIfChanged leafId=${leafId} last=${last ? last.entryId : "null"} tasks=${data.tasks.length}\n`);
|
|
67
|
+
if (last?.data) {
|
|
68
|
+
const s = last.data;
|
|
69
|
+
const sameTasks =
|
|
70
|
+
s.tasks.length === data.tasks.length &&
|
|
71
|
+
s.tasks.every(
|
|
72
|
+
(t, i) =>
|
|
73
|
+
t.id === data.tasks[i]!.id &&
|
|
74
|
+
t.content === data.tasks[i]!.content &&
|
|
75
|
+
t.status === data.tasks[i]!.status &&
|
|
76
|
+
t.priority === data.tasks[i]!.priority
|
|
77
|
+
);
|
|
78
|
+
const samePlan = s.planText === data.planText;
|
|
79
|
+
const sameMode = s.agentMode === data.agentMode;
|
|
80
|
+
const sameTools =
|
|
81
|
+
s.activeTools.length === data.activeTools.length &&
|
|
82
|
+
s.activeTools.every((t, i) => t === data.activeTools[i]);
|
|
83
|
+
|
|
84
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] saveSnapshotIfChanged compare sameTasks=${sameTasks} samePlan=${samePlan} sameMode=${sameMode} sameTools=${sameTools}\n`);
|
|
85
|
+
if (sameTasks && samePlan && sameMode && sameTools) {
|
|
86
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] saveSnapshotIfChanged -> skip saving (no change)\n`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const id = saveSnapshot(session, data);
|
|
92
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] saveSnapshotIfChanged -> saved new snapshot id=${id}\n`);
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Walk the branch path from root to `targetEntryId` backwards and return the
|
|
98
|
+
* nearest custom entry with type `koi:snapshot`.
|
|
99
|
+
* Used when loading a session (leaf is known).
|
|
100
|
+
*/
|
|
101
|
+
export function findSnapshotBeforeEntry(
|
|
102
|
+
session: AgentSession,
|
|
103
|
+
targetEntryId: string
|
|
104
|
+
): { entryId: string; data: KoiSnapshotData | undefined } | null {
|
|
105
|
+
const branch = session.sessionManager.getBranch(targetEntryId);
|
|
106
|
+
const snapshotIds: string[] = [];
|
|
107
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
108
|
+
const entry = branch[i]!;
|
|
109
|
+
if (
|
|
110
|
+
entry.type === "custom" &&
|
|
111
|
+
(entry as unknown as { customType?: string }).customType ===
|
|
112
|
+
SNAPSHOT_CUSTOM_TYPE
|
|
113
|
+
) {
|
|
114
|
+
snapshotIds.push(entry.id);
|
|
115
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] findSnapshotBeforeEntry target=${targetEntryId} found=${entry.id} tasks=${(entry as unknown as { data?: KoiSnapshotData }).data?.tasks?.length ?? 0} taskStatus=${(entry as unknown as { data?: KoiSnapshotData }).data?.tasks?.map(t => t.status).join(",") ?? "none"}\n`);
|
|
116
|
+
return {
|
|
117
|
+
entryId: entry.id,
|
|
118
|
+
data: (entry as unknown as { data?: KoiSnapshotData }).data,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] findSnapshotBeforeEntry target=${targetEntryId} no snapshot found on branch of ${branch.length} entries\n`);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Search forward from `targetEntryId` through its entire subtree (using all
|
|
128
|
+
* entries in the session file) to find the nearest snapshot.
|
|
129
|
+
* Used when forking: the snapshot lives after the selected entry, not on the
|
|
130
|
+
* branch path *to* the entry.
|
|
131
|
+
*/
|
|
132
|
+
export function findSnapshotAfterEntry(
|
|
133
|
+
session: AgentSession,
|
|
134
|
+
targetEntryId: string
|
|
135
|
+
): { entryId: string; data: KoiSnapshotData | undefined } | null {
|
|
136
|
+
const allEntries = session.sessionManager.getEntries();
|
|
137
|
+
const byId = new Map<string, SessionEntry>();
|
|
138
|
+
const children = new Map<string, string[]>();
|
|
139
|
+
|
|
140
|
+
for (const entry of allEntries) {
|
|
141
|
+
byId.set(entry.id, entry);
|
|
142
|
+
const parentId = entry.parentId ?? "root";
|
|
143
|
+
const list = children.get(parentId) ?? [];
|
|
144
|
+
list.push(entry.id);
|
|
145
|
+
children.set(parentId, list);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const queue = [targetEntryId];
|
|
149
|
+
const visited = new Set<string>();
|
|
150
|
+
const visitedSnapshots: string[] = [];
|
|
151
|
+
|
|
152
|
+
while (queue.length > 0) {
|
|
153
|
+
const id = queue.shift()!;
|
|
154
|
+
if (visited.has(id)) continue;
|
|
155
|
+
visited.add(id);
|
|
156
|
+
|
|
157
|
+
const entry = byId.get(id);
|
|
158
|
+
if (
|
|
159
|
+
entry &&
|
|
160
|
+
entry.type === "custom" &&
|
|
161
|
+
(entry as unknown as { customType?: string }).customType ===
|
|
162
|
+
SNAPSHOT_CUSTOM_TYPE
|
|
163
|
+
) {
|
|
164
|
+
visitedSnapshots.push(entry.id);
|
|
165
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] findSnapshotAfterEntry target=${targetEntryId} found=${entry.id} tasks=${(entry as unknown as { data?: KoiSnapshotData }).data?.tasks?.length ?? 0} taskStatus=${(entry as unknown as { data?: KoiSnapshotData }).data?.tasks?.map(t => t.status).join(",") ?? "none"} visitedSnapshots=[${visitedSnapshots.join(",")}]\n`);
|
|
166
|
+
return {
|
|
167
|
+
entryId: entry.id,
|
|
168
|
+
data: (entry as unknown as { data?: KoiSnapshotData }).data,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const childIds = children.get(id) ?? [];
|
|
173
|
+
queue.push(...childIds);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] findSnapshotAfterEntry target=${targetEntryId} no snapshot found in subtree visited=${visited.size} entries\n`);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Restore tasks and plan text from the nearest snapshot related to
|
|
182
|
+
* `targetEntryId`.
|
|
183
|
+
*
|
|
184
|
+
* Strategy:
|
|
185
|
+
* 1. Try to find a snapshot **after** the entry (forward in the subtree).
|
|
186
|
+
* This is the normal case when forking: the snapshot was appended as a
|
|
187
|
+
* child of the turn that ended at/after the selected entry.
|
|
188
|
+
* 2. If no forward snapshot exists, fall back to a snapshot **before/at**
|
|
189
|
+
* the entry (walking backward toward root). This handles the load-session
|
|
190
|
+
* case where the leaf itself may be a snapshot or the snapshot sits on the
|
|
191
|
+
* branch path before the leaf.
|
|
192
|
+
*
|
|
193
|
+
* Returns the snapshot data (including agentMode/activeTools) so the caller
|
|
194
|
+
* can restore mode state, or null if no snapshot was found.
|
|
195
|
+
*/
|
|
196
|
+
export function restoreSnapshot(
|
|
197
|
+
session: AgentSession,
|
|
198
|
+
targetEntryId: string,
|
|
199
|
+
taskManager: SessionTaskManager
|
|
200
|
+
): KoiSnapshotData | null {
|
|
201
|
+
let snapshot = findSnapshotAfterEntry(session, targetEntryId);
|
|
202
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] restoreSnapshot entryId=${targetEntryId} forward=${snapshot ? snapshot.entryId : "null"}\n`);
|
|
203
|
+
if (!snapshot?.data) {
|
|
204
|
+
snapshot = findSnapshotBeforeEntry(session, targetEntryId);
|
|
205
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] restoreSnapshot fallback=${snapshot ? snapshot.entryId : "null"}\n`);
|
|
206
|
+
}
|
|
207
|
+
if (!snapshot?.data) return null;
|
|
208
|
+
|
|
209
|
+
const taskStatuses = snapshot.data.tasks.map(t => `${t.id}:${t.status}`).join(", ");
|
|
210
|
+
fs.appendFileSync("/tmp/koi-snapshot-debug.log", `[snapshot] restoreSnapshot -> restoring tasks=[${taskStatuses}] plan=${snapshot.data.planText?.slice(0, 20) ?? "null"} mode=${snapshot.data.agentMode}\n`);
|
|
211
|
+
|
|
212
|
+
taskManager.setTasks(snapshot.data.tasks);
|
|
213
|
+
setCurrentPlanText(snapshot.data.planText ?? "");
|
|
214
|
+
|
|
215
|
+
return snapshot.data;
|
|
216
|
+
}
|