@schlenkr/ragents 0.1.6 → 0.1.8

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.
Files changed (150) hide show
  1. package/apps/server/src/chat-handler.ts +2 -0
  2. package/apps/server/src/config.ts +6 -0
  3. package/apps/server/src/host-api.json +1 -0
  4. package/apps/server/src/layout.ts +0 -15
  5. package/apps/server/src/plugin-support/agent-tool.ts +2 -3
  6. package/apps/server/src/plugin-support/plugin-folder.ts +2 -1
  7. package/apps/server/src/provider.ts +34 -11
  8. package/apps/server/src/ragents/actor-chat-history.ts +1 -0
  9. package/apps/server/src/ragents/engine.ts +23 -24
  10. package/apps/server/src/ragents/journal-chat-events.ts +2 -0
  11. package/apps/server/src/ragents/run-owner.ts +3 -2
  12. package/apps/server/src/ragents/session.ts +3 -3
  13. package/apps/server/src/run-preparation.ts +14 -18
  14. package/apps/server/src/run-transfer.ts +65 -4
  15. package/apps/server/src/settings.ts +2 -4
  16. package/apps/web/dist/assets/{highlighted-body-KPVGNVTW-B9qYBARS.js → highlighted-body-KPVGNVTW-xXXhTDbh.js} +1 -1
  17. package/apps/web/dist/assets/{main-BCjvD2Hh.js → main-C7JeZFjs.js} +1 -1
  18. package/apps/web/dist/assets/{mermaid-HWGCJPDP-CvYYFK_n.js → mermaid-HWGCJPDP-DfsU5ofC.js} +69 -69
  19. package/apps/web/dist/assets/{runPanel-CG7kRMJh.js → runPanel-ZT6TPDLM.js} +1 -1
  20. package/apps/web/dist/help/guide-clients.html +13 -4
  21. package/apps/web/dist/help/guide-clients.md +14 -4
  22. package/apps/web/dist/help/guide-distributed.html +8 -3
  23. package/apps/web/dist/help/guide-distributed.md +8 -3
  24. package/apps/web/dist/help/guide-runtime.html +18 -12
  25. package/apps/web/dist/help/guide-runtime.md +19 -12
  26. package/apps/web/dist/help/mini-app.css +23 -3
  27. package/apps/web/dist/host-web.json +4 -4
  28. package/apps/web/dist/index.html +2 -2
  29. package/apps/web/dist/run-panel.html +2 -2
  30. package/apps/web/src/SessionList.tsx +16 -8
  31. package/apps/web/src/StartTiles.tsx +1 -1
  32. package/apps/web/src/api.ts +2 -0
  33. package/apps/web/src/panel/PanelPage.tsx +1 -1
  34. package/apps/web/src/panel/RunLine.tsx +4 -2
  35. package/apps/web/src/panel/contract.ts +2 -0
  36. package/bundles/ragents.activity/ragents-bundle.json +1 -1
  37. package/bundles/ragents.actor-programs/ragents-bundle.json +1 -1
  38. package/bundles/ragents.ask/ragents-bundle.json +1 -1
  39. package/bundles/ragents.browser/ragents-bundle.json +1 -1
  40. package/bundles/ragents.documents/ragents-bundle.json +1 -1
  41. package/bundles/ragents.lsp-fsharp/ragents-bundle.json +1 -1
  42. package/bundles/ragents.lsp-roslyn/ragents-bundle.json +1 -1
  43. package/bundles/ragents.lsp-typescript/ragents-bundle.json +1 -1
  44. package/bundles/ragents.model-relay/ragents-bundle.json +1 -1
  45. package/bundles/ragents.orchestration/ragents-bundle.json +1 -1
  46. package/bundles/ragents.overseer/ragents-bundle.json +1 -1
  47. package/bundles/ragents.processes/ragents-bundle.json +1 -1
  48. package/bundles/ragents.product/ragents-bundle.json +1 -1
  49. package/bundles/ragents.profile-distribution/ragents-bundle.json +1 -1
  50. package/bundles/ragents.reference/ragents-bundle.json +2 -2
  51. package/bundles/ragents.reference/run-scripts/learning-afternoon/tests/program.test.ts +1 -1
  52. package/bundles/ragents.reference/run-scripts/word-game/tests/program.test.ts +3 -3
  53. package/bundles/ragents.todo/ragents-bundle.json +1 -1
  54. package/bundles/ragents.transcript/ragents-bundle.json +1 -1
  55. package/bundles/ragents.watch/ragents-bundle.json +1 -1
  56. package/bundles/ragents.workspace/ragents-bundle.json +1 -1
  57. package/package.json +2 -2
  58. package/packages/agent/dist/core/agent-settings.d.ts +2 -2
  59. package/packages/agent/dist/core/compaction/compaction.d.ts +16 -50
  60. package/packages/agent/dist/core/context-log.d.ts +25 -0
  61. package/packages/agent/dist/core/messages.d.ts +3 -10
  62. package/packages/agent/dist/core/tool-definition.d.ts +17 -0
  63. package/packages/agent/dist/core/tools/bash.d.ts +2 -7
  64. package/packages/agent/dist/core/tools/edit.d.ts +1 -1
  65. package/packages/agent/dist/core/tools/read.d.ts +1 -1
  66. package/packages/agent/dist/core/tools/write.d.ts +1 -1
  67. package/packages/agent/dist/index.d.ts +7 -8
  68. package/packages/agent/dist/loop/agent.d.ts +2 -6
  69. package/packages/agent/package.json +1 -1
  70. package/packages/agent/src/core/agent-settings.ts +2 -2
  71. package/packages/agent/src/core/compaction/compaction.ts +49 -175
  72. package/packages/agent/src/core/context-log.ts +49 -0
  73. package/packages/agent/src/core/messages.ts +5 -11
  74. package/packages/agent/src/core/tool-definition.ts +29 -0
  75. package/packages/agent/src/core/tools/bash.ts +2 -9
  76. package/packages/agent/src/core/tools/edit.ts +2 -11
  77. package/packages/agent/src/core/tools/read.ts +3 -18
  78. package/packages/agent/src/core/tools/write.ts +1 -5
  79. package/packages/agent/src/index.ts +28 -14
  80. package/packages/agent/src/loop/agent-loop.ts +1 -2
  81. package/packages/agent/src/loop/agent.ts +8 -2
  82. package/packages/ai/dist/api/ai-sdk.d.ts +2 -0
  83. package/packages/ai/dist/types.d.ts +2 -0
  84. package/packages/ai/src/api/ai-sdk-messages.ts +15 -10
  85. package/packages/ai/src/api/ai-sdk.ts +11 -0
  86. package/packages/ai/src/api/transform-messages.ts +1 -1
  87. package/packages/ai/src/types.ts +2 -0
  88. package/packages/ai/src/utils/retry.ts +14 -0
  89. package/packages/ragents/src/agents/actor-input.ts +15 -0
  90. package/packages/ragents/src/agents/model-context.ts +360 -0
  91. package/packages/ragents/src/agents/plugins.ts +5 -0
  92. package/packages/ragents/src/agents/scheduler.ts +3 -7
  93. package/packages/ragents/src/agents/tools.ts +2 -2
  94. package/packages/ragents/src/agents/toolset.ts +47 -20
  95. package/packages/ragents/src/agents/workspaces.ts +0 -6
  96. package/packages/ragents/src/domain/event-semantics.ts +51 -0
  97. package/packages/ragents/src/domain/event-validation.ts +166 -0
  98. package/packages/ragents/src/domain/events.ts +72 -0
  99. package/packages/ragents/src/domain/model.ts +4 -2
  100. package/packages/ragents/src/domain/projection.ts +9 -0
  101. package/packages/ragents/src/domain/tool-result-text.ts +9 -0
  102. package/packages/ragents/src/drivers/agent-hooks.ts +25 -38
  103. package/packages/ragents/src/drivers/agent-runtime.ts +118 -933
  104. package/packages/ragents/src/drivers/agent-turn.ts +735 -0
  105. package/packages/ragents/src/drivers/agent.ts +9 -7
  106. package/packages/ragents/src/drivers/skill-preload.ts +53 -63
  107. package/packages/ragents/src/drivers/types.ts +21 -5
  108. package/packages/ragents/src/index.ts +8 -6
  109. package/packages/ragents/src/plugin-host.ts +60 -13
  110. package/packages/ragents/src/plugin-types.ts +7 -2
  111. package/packages/ragents/src/runtime/decisions/actors.ts +10 -0
  112. package/packages/ragents/src/runtime/decisions/turns.ts +66 -7
  113. package/packages/ragents/src/runtime/journal-storage.ts +7 -4
  114. package/packages/ragents/src/runtime/journal.ts +35 -0
  115. package/packages/ragents/src/runtime/orchestration.ts +140 -9
  116. package/packages/workspace-executor/src/sandbox-tools.ts +10 -11
  117. package/plugins/ragents.reference/run-scripts/learning-afternoon/tests/program.test.ts +1 -1
  118. package/plugins/ragents.reference/run-scripts/word-game/tests/program.test.ts +3 -3
  119. package/scripts/agent/journal.ts +5 -3
  120. package/scripts/package/publish-package.ts +4 -3
  121. package/packages/agent/dist/core/agent-session-runtime.d.ts +0 -33
  122. package/packages/agent/dist/core/agent-session-services.d.ts +0 -35
  123. package/packages/agent/dist/core/agent-session.d.ts +0 -234
  124. package/packages/agent/dist/core/auth-guidance.d.ts +0 -4
  125. package/packages/agent/dist/core/defaults.d.ts +0 -3
  126. package/packages/agent/dist/core/extensions/index.d.ts +0 -11
  127. package/packages/agent/dist/core/extensions/loader.d.ts +0 -12
  128. package/packages/agent/dist/core/extensions/runner.d.ts +0 -39
  129. package/packages/agent/dist/core/extensions/types.d.ts +0 -156
  130. package/packages/agent/dist/core/extensions/wrapper.d.ts +0 -12
  131. package/packages/agent/dist/core/resource-loader.d.ts +0 -39
  132. package/packages/agent/dist/core/sdk.d.ts +0 -38
  133. package/packages/agent/dist/core/session-manager.d.ts +0 -189
  134. package/packages/agent/dist/core/source-info.d.ts +0 -16
  135. package/packages/agent/dist/core/system-prompt.d.ts +0 -21
  136. package/packages/agent/src/core/agent-session-runtime.ts +0 -94
  137. package/packages/agent/src/core/agent-session-services.ts +0 -74
  138. package/packages/agent/src/core/agent-session.ts +0 -1108
  139. package/packages/agent/src/core/auth-guidance.ts +0 -14
  140. package/packages/agent/src/core/defaults.ts +0 -3
  141. package/packages/agent/src/core/extensions/index.ts +0 -38
  142. package/packages/agent/src/core/extensions/loader.ts +0 -125
  143. package/packages/agent/src/core/extensions/runner.ts +0 -211
  144. package/packages/agent/src/core/extensions/types.ts +0 -197
  145. package/packages/agent/src/core/extensions/wrapper.ts +0 -24
  146. package/packages/agent/src/core/resource-loader.ts +0 -142
  147. package/packages/agent/src/core/sdk.ts +0 -123
  148. package/packages/agent/src/core/session-manager.ts +0 -743
  149. package/packages/agent/src/core/source-info.ts +0 -28
  150. package/packages/agent/src/core/system-prompt.ts +0 -108
@@ -1,743 +0,0 @@
1
- import { type AgentMessage, uuidv7 } from "../loop/index.ts";
2
- import type { Message } from "@ragents/ai";
3
- import { randomUUID } from "crypto";
4
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readSync, statSync, writeFileSync } from "fs";
5
- import { join, resolve } from "path";
6
- import { StringDecoder } from "string_decoder";
7
- import { normalizePath, resolvePath } from "../utils/paths.ts";
8
- import { type CustomMessage, createCompactionSummaryMessage } from "./messages.ts";
9
-
10
- export const CURRENT_SESSION_VERSION = 3;
11
-
12
- export interface SessionHeader {
13
- type: "session";
14
- version?: number; // v1 sessions don't have this
15
- id: string;
16
- timestamp: string;
17
- cwd: string;
18
- parentSession?: string;
19
- }
20
-
21
- export interface NewSessionOptions {
22
- id?: string;
23
- parentSession?: string;
24
- }
25
-
26
- export interface SessionEntryBase {
27
- type: string;
28
- id: string;
29
- parentId: string | null;
30
- timestamp: string;
31
- }
32
-
33
- export interface SessionMessageEntry extends SessionEntryBase {
34
- type: "message";
35
- message: AgentMessage;
36
- }
37
-
38
- export interface ThinkingLevelChangeEntry extends SessionEntryBase {
39
- type: "thinking_level_change";
40
- thinkingLevel: string;
41
- }
42
-
43
- export interface ModelChangeEntry extends SessionEntryBase {
44
- type: "model_change";
45
- provider: string;
46
- modelId: string;
47
- }
48
-
49
- export interface CompactionEntry<T = unknown> extends SessionEntryBase {
50
- type: "compaction";
51
- summary: string;
52
- firstKeptEntryId: string;
53
- tokensBefore: number;
54
- /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
55
- details?: T;
56
- /** True if generated by an extension, undefined/false if agent-generated (backward compatible) */
57
- fromHook?: boolean;
58
- }
59
-
60
- /**
61
- * Custom entry for extensions to store extension-specific data in the session.
62
- * Use customType to identify your extension's entries.
63
- *
64
- * Purpose: Persist extension state across session reloads. On reload, extensions can
65
- * scan entries for their customType and reconstruct internal state.
66
- *
67
- * Does NOT participate in LLM context (ignored by buildSessionContext).
68
- * For injecting content into context, see CustomMessageEntry.
69
- */
70
- export interface CustomEntry<T = unknown> extends SessionEntryBase {
71
- type: "custom";
72
- customType: string;
73
- data?: T;
74
- }
75
-
76
- /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
77
- export type SessionEntry =
78
- | SessionMessageEntry
79
- | ThinkingLevelChangeEntry
80
- | ModelChangeEntry
81
- | CompactionEntry
82
- | CustomEntry;
83
-
84
- /** Raw file entry (includes header) */
85
- export type FileEntry = SessionHeader | SessionEntry;
86
-
87
- export interface SessionContext {
88
- messages: AgentMessage[];
89
- thinkingLevel: string;
90
- model: { provider: string; modelId: string } | null;
91
- }
92
-
93
- export type ReadonlySessionManager = Pick<SessionManager, "getBranch">;
94
-
95
- function createSessionId(): string {
96
- return uuidv7();
97
- }
98
-
99
- export function assertValidSessionId(id: string): void {
100
- if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
101
- throw new Error(
102
- "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character",
103
- );
104
- }
105
- }
106
-
107
- /** Generate a unique short ID (8 hex chars, collision-checked) */
108
- function generateId(byId: { has(id: string): boolean }): string {
109
- for (let i = 0; i < 100; i++) {
110
- const id = randomUUID().slice(0, 8);
111
- if (!byId.has(id)) return id;
112
- }
113
- // Fallback to full UUID if somehow we have collisions
114
- return randomUUID();
115
- }
116
-
117
- /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
118
- function migrateV1ToV2(entries: FileEntry[]): void {
119
- const ids = new Set<string>();
120
- let prevId: string | null = null;
121
-
122
- for (const entry of entries) {
123
- if (entry.type === "session") {
124
- entry.version = 2;
125
- continue;
126
- }
127
-
128
- entry.id = generateId(ids);
129
- entry.parentId = prevId;
130
- prevId = entry.id;
131
-
132
- // Convert firstKeptEntryIndex to firstKeptEntryId for compaction
133
- if (entry.type === "compaction") {
134
- const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
135
- if (typeof comp.firstKeptEntryIndex === "number") {
136
- const targetEntry = entries[comp.firstKeptEntryIndex];
137
- if (targetEntry && targetEntry.type !== "session") {
138
- comp.firstKeptEntryId = targetEntry.id;
139
- }
140
- delete comp.firstKeptEntryIndex;
141
- }
142
- }
143
- }
144
- }
145
-
146
- /** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
147
- function migrateV2ToV3(entries: FileEntry[]): void {
148
- for (const entry of entries) {
149
- if (entry.type === "session") {
150
- entry.version = 3;
151
- continue;
152
- }
153
-
154
- // Update message entries with hookMessage role
155
- if (entry.type === "message") {
156
- const msgEntry = entry as SessionMessageEntry;
157
- if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") {
158
- (msgEntry.message as { role: string }).role = "custom";
159
- }
160
- }
161
- }
162
- }
163
-
164
- /**
165
- * Run all necessary migrations to bring entries to current version.
166
- * Mutates entries in place. Returns true if any migration was applied.
167
- */
168
- function migrateToCurrentVersion(entries: FileEntry[]): boolean {
169
- const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
170
- const version = header?.version ?? 1;
171
-
172
- if (version >= CURRENT_SESSION_VERSION) return false;
173
-
174
- if (version < 2) migrateV1ToV2(entries);
175
- if (version < 3) migrateV2ToV3(entries);
176
-
177
- return true;
178
- }
179
-
180
- export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
181
- for (let i = entries.length - 1; i >= 0; i--) {
182
- if (entries[i].type === "compaction") {
183
- return entries[i] as CompactionEntry;
184
- }
185
- }
186
- return null;
187
- }
188
-
189
- function buildEntryIndex(entries: SessionEntry[], byId?: Map<string, SessionEntry>): Map<string, SessionEntry> {
190
- if (byId) return byId;
191
- const index = new Map<string, SessionEntry>();
192
- for (const entry of entries) {
193
- index.set(entry.id, entry);
194
- }
195
- return index;
196
- }
197
-
198
- function buildSessionPath(
199
- entries: SessionEntry[],
200
- leafId?: string | null,
201
- byId?: Map<string, SessionEntry>,
202
- ): SessionEntry[] {
203
- const index = buildEntryIndex(entries, byId);
204
- let leaf: SessionEntry | undefined;
205
- if (leafId === null) {
206
- return [];
207
- }
208
- if (leafId) {
209
- leaf = index.get(leafId);
210
- }
211
- leaf ??= entries[entries.length - 1];
212
- if (!leaf) {
213
- return [];
214
- }
215
-
216
- const path: SessionEntry[] = [];
217
- let current: SessionEntry | undefined = leaf;
218
- while (current) {
219
- path.push(current);
220
- current = current.parentId ? index.get(current.parentId) : undefined;
221
- }
222
- path.reverse();
223
- return path;
224
- }
225
-
226
- function getSessionContextSettings(path: SessionEntry[]): Pick<SessionContext, "thinkingLevel" | "model"> {
227
- let thinkingLevel = "off";
228
- let model: { provider: string; modelId: string } | null = null;
229
-
230
- for (const entry of path) {
231
- if (entry.type === "thinking_level_change") {
232
- thinkingLevel = entry.thinkingLevel;
233
- } else if (entry.type === "model_change") {
234
- model = { provider: entry.provider, modelId: entry.modelId };
235
- } else if (entry.type === "message" && entry.message.role === "assistant") {
236
- model = { provider: entry.message.provider, modelId: entry.message.model };
237
- }
238
- }
239
-
240
- return { thinkingLevel, model };
241
- }
242
-
243
- /**
244
- * Project one selected session entry into LLM/runtime messages.
245
- * Plain custom entries are display/state entries and do not participate in context.
246
- */
247
- export function sessionEntryToContextMessages(entry: SessionEntry): AgentMessage[] {
248
- if (entry.type === "message") {
249
- const message = entry.message;
250
- // Session files are parsed without validation; old versions, forks, or
251
- // hand-edited files can contain messages with null/missing content.
252
- if (
253
- (message.role === "user" || message.role === "assistant" || message.role === "toolResult") &&
254
- message.content == null
255
- ) {
256
- return [{ ...message, content: [] }];
257
- }
258
- return [message];
259
- }
260
- if (entry.type === "compaction") {
261
- return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)];
262
- }
263
- return [];
264
- }
265
-
266
- /**
267
- * Build the active, compaction-aware session entry list.
268
- *
269
- * This follows the current leaf path. If the path contains compaction entries,
270
- * the latest compaction is represented by the compaction entry itself, followed
271
- * by the kept entries starting at firstKeptEntryId and all entries after the
272
- * compaction entry. Older summarized entries are omitted.
273
- */
274
- export function buildContextEntries(
275
- entries: SessionEntry[],
276
- leafId?: string | null,
277
- byId?: Map<string, SessionEntry>,
278
- ): SessionEntry[] {
279
- const path = buildSessionPath(entries, leafId, byId);
280
- let compaction: CompactionEntry | null = null;
281
-
282
- for (const entry of path) {
283
- if (entry.type === "compaction") {
284
- compaction = entry;
285
- }
286
- }
287
-
288
- if (!compaction) {
289
- return path;
290
- }
291
-
292
- const compactionIdx = path.findIndex((entry) => entry.id === compaction.id);
293
- if (compactionIdx < 0) {
294
- return path;
295
- }
296
-
297
- const contextEntries: SessionEntry[] = [compaction];
298
- let foundFirstKept = false;
299
- for (let i = 0; i < compactionIdx; i++) {
300
- const entry = path[i];
301
- if (entry.id === compaction.firstKeptEntryId) {
302
- foundFirstKept = true;
303
- }
304
- if (foundFirstKept) {
305
- contextEntries.push(entry);
306
- }
307
- }
308
- contextEntries.push(...path.slice(compactionIdx + 1));
309
- return contextEntries;
310
- }
311
-
312
- /**
313
- * Build the session context from entries using tree traversal.
314
- * If leafId is provided, walks from that entry to root.
315
- * Handles compaction and branch summaries along the path.
316
- */
317
- export function buildSessionContext(
318
- entries: SessionEntry[],
319
- leafId?: string | null,
320
- byId?: Map<string, SessionEntry>,
321
- ): SessionContext {
322
- const path = buildSessionPath(entries, leafId, byId);
323
- const { thinkingLevel, model } = getSessionContextSettings(path);
324
- const messages = buildContextEntries(entries, leafId, byId).flatMap(sessionEntryToContextMessages);
325
- return { messages, thinkingLevel, model };
326
- }
327
-
328
- const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
329
-
330
- function parseSessionEntryLine(line: string): FileEntry | null {
331
- if (!line.trim()) return null;
332
- try {
333
- return JSON.parse(line) as FileEntry;
334
- } catch {
335
- // Skip malformed lines
336
- return null;
337
- }
338
- }
339
-
340
- /** Exported for testing */
341
- export function loadEntriesFromFile(filePath: string): FileEntry[] {
342
- const resolvedFilePath = normalizePath(filePath);
343
- if (!existsSync(resolvedFilePath)) return [];
344
-
345
- const entries: FileEntry[] = [];
346
- const fd = openSync(resolvedFilePath, "r");
347
- try {
348
- const decoder = new StringDecoder("utf8");
349
- const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
350
- let pending = "";
351
-
352
- while (true) {
353
- const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
354
- if (bytesRead === 0) break;
355
-
356
- pending += decoder.write(buffer.subarray(0, bytesRead));
357
- let lineStart = 0;
358
- let newlineIndex = pending.indexOf("\n", lineStart);
359
- while (newlineIndex !== -1) {
360
- const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
361
- if (entry) entries.push(entry);
362
- lineStart = newlineIndex + 1;
363
- newlineIndex = pending.indexOf("\n", lineStart);
364
- }
365
- pending = pending.slice(lineStart);
366
- }
367
-
368
- pending += decoder.end();
369
- const finalEntry = parseSessionEntryLine(pending);
370
- if (finalEntry) entries.push(finalEntry);
371
- } finally {
372
- closeSync(fd);
373
- }
374
-
375
- // Validate session header
376
- if (entries.length === 0) return entries;
377
- const header = entries[0];
378
- if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") {
379
- return [];
380
- }
381
-
382
- return entries;
383
- }
384
-
385
- /**
386
- * Manages conversation sessions as append-only trees stored in JSONL files.
387
- *
388
- * Each session entry has an id and parentId forming a tree structure. The "leaf"
389
- * pointer tracks the current position. Appending creates a child of the current leaf.
390
- * Branching moves the leaf to an earlier entry, allowing new branches without
391
- * modifying history.
392
- *
393
- * Use buildSessionContext() to get the resolved message list for the LLM, which
394
- * handles compaction summaries and follows the path from root to current leaf.
395
- */
396
- export class SessionManager {
397
- private sessionId: string = "";
398
- private sessionFile: string | undefined;
399
- private sessionDir: string;
400
- private cwd: string;
401
- private persist: boolean;
402
- private flushed: boolean = false;
403
- private fileEntries: FileEntry[] = [];
404
- private byId: Map<string, SessionEntry> = new Map();
405
- private leafId: string | null = null;
406
-
407
- private constructor(
408
- cwd: string,
409
- sessionDir: string,
410
- sessionFile: string | undefined,
411
- persist: boolean,
412
- newSessionOptions?: NewSessionOptions,
413
- ) {
414
- this.cwd = resolvePath(cwd);
415
- this.sessionDir = normalizePath(sessionDir);
416
- this.persist = persist;
417
- if (persist && this.sessionDir && !existsSync(this.sessionDir)) {
418
- mkdirSync(this.sessionDir, { recursive: true });
419
- }
420
-
421
- if (sessionFile) {
422
- this.setSessionFile(sessionFile);
423
- } else {
424
- this.newSession(newSessionOptions);
425
- }
426
- }
427
-
428
- /** Switch to a different session file (used for resume and branching) */
429
- setSessionFile(sessionFile: string): void {
430
- this.sessionFile = resolvePath(sessionFile);
431
- if (existsSync(this.sessionFile)) {
432
- this.fileEntries = loadEntriesFromFile(this.sessionFile);
433
-
434
- // If file was empty, initialize it with a valid session header. If it was
435
- // non-empty but did not parse as an agent session, fail without modifying it.
436
- if (this.fileEntries.length === 0) {
437
- const explicitPath = this.sessionFile;
438
- if (statSync(explicitPath).size > 0) {
439
- throw new Error(`Session file is not a valid agent session: ${explicitPath}`);
440
- }
441
- this.newSession();
442
- this.sessionFile = explicitPath;
443
- this._rewriteFile();
444
- this.flushed = true;
445
- return;
446
- }
447
-
448
- const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
449
- this.sessionId = header?.id ?? createSessionId();
450
-
451
- if (migrateToCurrentVersion(this.fileEntries)) {
452
- this._rewriteFile();
453
- }
454
-
455
- this._buildIndex();
456
- this.flushed = true;
457
- } else {
458
- const explicitPath = this.sessionFile;
459
- this.newSession();
460
- this.sessionFile = explicitPath; // preserve explicit path from --session flag
461
- }
462
- }
463
-
464
- newSession(options?: NewSessionOptions): string | undefined {
465
- if (options?.id !== undefined) {
466
- assertValidSessionId(options.id);
467
- }
468
- this.sessionId = options?.id ?? createSessionId();
469
- const timestamp = new Date().toISOString();
470
- const header: SessionHeader = {
471
- type: "session",
472
- version: CURRENT_SESSION_VERSION,
473
- id: this.sessionId,
474
- timestamp,
475
- cwd: this.cwd,
476
- parentSession: options?.parentSession,
477
- };
478
- this.fileEntries = [header];
479
- this.byId.clear();
480
- this.leafId = null;
481
- this.flushed = false;
482
-
483
- if (this.persist) {
484
- const fileTimestamp = timestamp.replace(/[:.]/g, "-");
485
- this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
486
- }
487
- return this.sessionFile;
488
- }
489
-
490
- private _buildIndex(): void {
491
- this.byId.clear();
492
- this.leafId = null;
493
- for (const entry of this.fileEntries) {
494
- if (entry.type === "session") continue;
495
- this.byId.set(entry.id, entry);
496
- this.leafId = entry.id;
497
- }
498
- }
499
-
500
- private _rewriteFile(): void {
501
- if (!this.persist || !this.sessionFile) return;
502
- const fd = openSync(this.sessionFile, "w");
503
- try {
504
- for (const entry of this.fileEntries) {
505
- writeFileSync(fd, `${JSON.stringify(entry)}\n`);
506
- }
507
- } finally {
508
- closeSync(fd);
509
- }
510
- }
511
-
512
- isPersisted(): boolean {
513
- return this.persist;
514
- }
515
-
516
- getCwd(): string {
517
- return this.cwd;
518
- }
519
-
520
- getSessionDir(): string {
521
- return this.sessionDir;
522
- }
523
-
524
- getSessionId(): string {
525
- return this.sessionId;
526
- }
527
-
528
- getSessionFile(): string | undefined {
529
- return this.sessionFile;
530
- }
531
-
532
- _persist(entry: SessionEntry): void {
533
- if (!this.persist || !this.sessionFile) return;
534
-
535
- const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
536
- if (!hasAssistant) {
537
- if (this.flushed) {
538
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
539
- } else {
540
- // Mark as not flushed so when assistant arrives, all entries get written
541
- this.flushed = false;
542
- }
543
- return;
544
- }
545
-
546
- if (!this.flushed) {
547
- const fd = openSync(this.sessionFile, "wx");
548
- try {
549
- for (const e of this.fileEntries) {
550
- writeFileSync(fd, `${JSON.stringify(e)}\n`);
551
- }
552
- } finally {
553
- closeSync(fd);
554
- }
555
- this.flushed = true;
556
- } else {
557
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
558
- }
559
- }
560
-
561
- private _appendEntry(entry: SessionEntry): void {
562
- this.fileEntries.push(entry);
563
- this.byId.set(entry.id, entry);
564
- this.leafId = entry.id;
565
- this._persist(entry);
566
- }
567
-
568
- /** Append a message as child of current leaf, then advance leaf. Returns entry id.
569
- * Compaction summaries are top-level entries appended via appendCompaction(), not message entries.
570
- */
571
- appendMessage(message: Message | CustomMessage): string {
572
- const entry: SessionMessageEntry = {
573
- type: "message",
574
- id: generateId(this.byId),
575
- parentId: this.leafId,
576
- timestamp: new Date().toISOString(),
577
- message,
578
- };
579
- this._appendEntry(entry);
580
- return entry.id;
581
- }
582
-
583
- /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
584
- appendThinkingLevelChange(thinkingLevel: string): string {
585
- const entry: ThinkingLevelChangeEntry = {
586
- type: "thinking_level_change",
587
- id: generateId(this.byId),
588
- parentId: this.leafId,
589
- timestamp: new Date().toISOString(),
590
- thinkingLevel,
591
- };
592
- this._appendEntry(entry);
593
- return entry.id;
594
- }
595
-
596
- /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
597
- appendModelChange(provider: string, modelId: string): string {
598
- const entry: ModelChangeEntry = {
599
- type: "model_change",
600
- id: generateId(this.byId),
601
- parentId: this.leafId,
602
- timestamp: new Date().toISOString(),
603
- provider,
604
- modelId,
605
- };
606
- this._appendEntry(entry);
607
- return entry.id;
608
- }
609
-
610
- /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
611
- appendCompaction<T = unknown>(
612
- summary: string,
613
- firstKeptEntryId: string,
614
- tokensBefore: number,
615
- details?: T,
616
- fromHook?: boolean,
617
- ): string {
618
- const entry: CompactionEntry<T> = {
619
- type: "compaction",
620
- id: generateId(this.byId),
621
- parentId: this.leafId,
622
- timestamp: new Date().toISOString(),
623
- summary,
624
- firstKeptEntryId,
625
- tokensBefore,
626
- details,
627
- fromHook,
628
- };
629
- this._appendEntry(entry);
630
- return entry.id;
631
- }
632
-
633
- /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
634
- appendCustomEntry(customType: string, data?: unknown): string {
635
- const entry: CustomEntry = {
636
- type: "custom",
637
- customType,
638
- data,
639
- id: generateId(this.byId),
640
- parentId: this.leafId,
641
- timestamp: new Date().toISOString(),
642
- };
643
- this._appendEntry(entry);
644
- return entry.id;
645
- }
646
-
647
- // =========================================================================
648
- // Tree Traversal
649
- // =========================================================================
650
-
651
- getLeafId(): string | null {
652
- return this.leafId;
653
- }
654
-
655
- getLeafEntry(): SessionEntry | undefined {
656
- return this.leafId ? this.byId.get(this.leafId) : undefined;
657
- }
658
-
659
- getEntry(id: string): SessionEntry | undefined {
660
- return this.byId.get(id);
661
- }
662
-
663
- /**
664
- * Walk from entry to root, returning all entries in path order.
665
- * Includes all entry types (messages, compaction, model changes, etc.).
666
- * Use buildSessionContext() to get the resolved messages for the LLM.
667
- */
668
- getBranch(fromId?: string): SessionEntry[] {
669
- const path: SessionEntry[] = [];
670
- const startId = fromId ?? this.leafId;
671
- let current = startId ? this.byId.get(startId) : undefined;
672
- while (current) {
673
- path.push(current);
674
- current = current.parentId ? this.byId.get(current.parentId) : undefined;
675
- }
676
- path.reverse();
677
- return path;
678
- }
679
-
680
- /**
681
- * Build the active, compaction-aware entry list for context/rendering.
682
- * Uses tree traversal from current leaf.
683
- */
684
- buildContextEntries(): SessionEntry[] {
685
- return buildContextEntries(this.getEntries(), this.leafId, this.byId);
686
- }
687
-
688
- /**
689
- * Build the session context (what gets sent to the LLM).
690
- * Uses tree traversal from current leaf.
691
- */
692
- buildSessionContext(): SessionContext {
693
- return buildSessionContext(this.getEntries(), this.leafId, this.byId);
694
- }
695
-
696
- /**
697
- * Get session header.
698
- */
699
- getHeader(): SessionHeader | null {
700
- const h = this.fileEntries.find((e) => e.type === "session");
701
- return h ? (h as SessionHeader) : null;
702
- }
703
-
704
- /**
705
- * Get all session entries (excludes header). Returns a shallow copy.
706
- * The session is append-only: use appendXXX() to add entries, branch() to
707
- * change the leaf pointer. Entries cannot be modified or deleted.
708
- */
709
- getEntries(): SessionEntry[] {
710
- return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");
711
- }
712
-
713
- /**
714
- * Create a new session.
715
- * @param cwd Working directory (stored in session header)
716
- * @param sessionDir Session directory
717
- */
718
- static create(cwd: string, sessionDir: string, options?: NewSessionOptions): SessionManager {
719
- return new SessionManager(cwd, normalizePath(sessionDir), undefined, true, options);
720
- }
721
-
722
- /**
723
- * Open a specific session file.
724
- * @param path Path to session file
725
- * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
726
- * @param cwdOverride Optional cwd override instead of the session header cwd.
727
- */
728
- static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
729
- const resolvedPath = resolvePath(path);
730
- // Extract cwd from session header if possible, otherwise use process.cwd()
731
- const entries = loadEntriesFromFile(resolvedPath);
732
- const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
733
- const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
734
- // If no sessionDir provided, derive from file's parent directory
735
- const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");
736
- return new SessionManager(cwd, dir, resolvedPath, true);
737
- }
738
-
739
- /** Create an in-memory session (no file persistence) */
740
- static inMemory(cwd: string = process.cwd(), options?: NewSessionOptions): SessionManager {
741
- return new SessionManager(cwd, "", undefined, false, options);
742
- }
743
- }