@gajae-code/coding-agent 0.7.3 → 0.7.5
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 +58 -0
- package/bin/gjc.js +4 -0
- package/dist/types/cli/plugin-cli.d.ts +2 -0
- package/dist/types/commands/plugin.d.ts +6 -0
- package/dist/types/commands/session.d.ts +6 -0
- package/dist/types/config/model-profile-activation.d.ts +8 -1
- package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
- package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +30 -2
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/model-selector.d.ts +6 -0
- package/dist/types/notifications/html-format.d.ts +11 -0
- package/dist/types/notifications/index.d.ts +149 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
- package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +35 -0
- package/dist/types/notifications/telegram-daemon.d.ts +60 -0
- package/dist/types/notifications/telegram-reference.d.ts +3 -1
- package/dist/types/notifications/topic-registry.d.ts +10 -9
- package/dist/types/runtime-mcp/types.d.ts +7 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +25 -0
- package/dist/types/session/session-manager.d.ts +57 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/utils/changelog.d.ts +1 -0
- package/package.json +11 -9
- package/scripts/g004-tmux-smoke.ts +100 -0
- package/scripts/g005-daemon-smoke.ts +181 -0
- package/scripts/g011-daemon-path-smoke.ts +153 -0
- package/src/cli/plugin-cli.ts +66 -3
- package/src/cli.ts +21 -4
- package/src/commands/plugin.ts +4 -0
- package/src/commands/session.ts +18 -0
- package/src/config/model-profile-activation.ts +55 -7
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -3
- package/src/defaults/gjc/skills/team/SKILL.md +5 -4
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
- package/src/export/html/index.ts +2 -2
- package/src/extensibility/gjc-plugins/compiler.ts +351 -0
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +109 -0
- package/src/extensibility/gjc-plugins/installer.ts +434 -0
- package/src/extensibility/gjc-plugins/loader.ts +3 -1
- package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
- package/src/extensibility/gjc-plugins/observability.ts +84 -0
- package/src/extensibility/gjc-plugins/paths.ts +1 -1
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
- package/src/extensibility/gjc-plugins/registry.ts +180 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
- package/src/extensibility/gjc-plugins/schema.ts +250 -20
- package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
- package/src/extensibility/gjc-plugins/types.ts +199 -3
- package/src/extensibility/gjc-plugins/validation.ts +80 -0
- package/src/extensibility/skills.ts +15 -0
- package/src/gjc-runtime/launch-tmux.ts +58 -15
- package/src/gjc-runtime/psmux-detect.ts +239 -0
- package/src/gjc-runtime/team-runtime.ts +56 -23
- package/src/gjc-runtime/tmux-common.ts +85 -3
- package/src/gjc-runtime/tmux-sessions.ts +111 -9
- package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/main.ts +14 -3
- package/src/modes/components/assistant-message.ts +49 -1
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +67 -43
- package/src/modes/components/model-selector.ts +44 -11
- package/src/modes/controllers/extension-ui-controller.ts +0 -27
- package/src/modes/controllers/selector-controller.ts +50 -11
- package/src/modes/interactive-mode.ts +2 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/notifications/html-format.ts +38 -0
- package/src/notifications/index.ts +242 -12
- package/src/notifications/lifecycle-commands.ts +228 -0
- package/src/notifications/lifecycle-control-runtime.ts +400 -0
- package/src/notifications/lifecycle-orchestrator.ts +358 -0
- package/src/notifications/rate-limit-pool.ts +19 -0
- package/src/notifications/recent-activity.ts +132 -0
- package/src/notifications/telegram-daemon.ts +433 -8
- package/src/notifications/telegram-reference.ts +25 -7
- package/src/notifications/topic-registry.ts +18 -9
- package/src/prompts/agents/executor.md +2 -2
- package/src/runtime-mcp/transports/stdio.ts +38 -4
- package/src/runtime-mcp/types.ts +7 -0
- package/src/sdk.ts +157 -10
- package/src/session/agent-session.ts +166 -74
- package/src/session/blob-store.ts +196 -8
- package/src/session/session-manager.ts +739 -12
- package/src/slash-commands/builtin-registry.ts +23 -3
- package/src/slash-commands/helpers/fast-status-report.ts +13 -3
- package/src/system-prompt.ts +9 -0
- package/src/task/executor.ts +31 -7
- package/src/task/index.ts +2 -0
- package/src/tools/ask.ts +5 -1
- package/src/tools/index.ts +3 -1
- package/src/utils/changelog.ts +8 -0
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
isBlobRef,
|
|
42
42
|
isImageDataUrl,
|
|
43
43
|
MemoryBlobStore,
|
|
44
|
+
parseBlobRef,
|
|
44
45
|
ResidentBlobMissingError,
|
|
45
46
|
resolveImageData,
|
|
46
47
|
resolveImageDataUrl,
|
|
@@ -93,9 +94,55 @@ export interface SessionEntryBase {
|
|
|
93
94
|
timestamp: string;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
export interface ColdSpillRef {
|
|
98
|
+
kind: "cold_spill";
|
|
99
|
+
ref: string;
|
|
100
|
+
encoding: "utf8" | "json";
|
|
101
|
+
originalChars: number;
|
|
102
|
+
sha256: string;
|
|
103
|
+
bytes: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface EvictedContentMarker {
|
|
107
|
+
evictedAt: number;
|
|
108
|
+
reason: "compacted_history";
|
|
109
|
+
compactionEntryId: string;
|
|
110
|
+
firstKeptEntryId: string;
|
|
111
|
+
payloads: Record<string, ColdSpillRef>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface EvictCompactedContentResult {
|
|
115
|
+
evictedEntries: number;
|
|
116
|
+
hotCharsRemoved: number;
|
|
117
|
+
coldBlobBytes: number;
|
|
118
|
+
payloadRefs: number;
|
|
119
|
+
alreadyEvictedEntries: number;
|
|
120
|
+
coldSpillWriteCount: number;
|
|
121
|
+
coldSpillReadCount: number;
|
|
122
|
+
residentTextReadCount: number;
|
|
123
|
+
residentImageReadCount: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface SessionManagerObservabilityStats {
|
|
127
|
+
coldSpillWriteCount: number;
|
|
128
|
+
coldSpillReadCount: number;
|
|
129
|
+
residentTextReadCount: number;
|
|
130
|
+
residentImageReadCount: number;
|
|
131
|
+
publicMaterializerCallCount: number;
|
|
132
|
+
getEntryMaterializerCallCount: number;
|
|
133
|
+
getBranchMaterializerCallCount: number;
|
|
134
|
+
getEntriesMaterializerCallCount: number;
|
|
135
|
+
materializedEntriesCachePopulateCount: number;
|
|
136
|
+
pathOnlyContextBuildCount: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
96
139
|
export interface SessionMessageEntry extends SessionEntryBase {
|
|
97
140
|
type: "message";
|
|
98
141
|
message: AgentMessage;
|
|
142
|
+
/** Cold-spill marker: when present, heavy message content was moved to durable
|
|
143
|
+
* content-addressed blobs after compaction. The marker is entry-level session
|
|
144
|
+
* metadata (not a message field) so strict message types stay intact. */
|
|
145
|
+
evictedContent?: EvictedContentMarker;
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
|
|
@@ -227,6 +274,8 @@ export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
|
|
227
274
|
display: boolean;
|
|
228
275
|
/** Who initiated this message for billing/attribution semantics. */
|
|
229
276
|
attribution?: MessageAttribution;
|
|
277
|
+
/** Cold-spill marker for custom-message content evicted after compaction. */
|
|
278
|
+
evictedContent?: EvictedContentMarker;
|
|
230
279
|
}
|
|
231
280
|
|
|
232
281
|
/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
|
|
@@ -319,6 +368,21 @@ function createSessionId(): string {
|
|
|
319
368
|
return Bun.randomUUIDv7();
|
|
320
369
|
}
|
|
321
370
|
|
|
371
|
+
/**
|
|
372
|
+
* A session id pre-allocated by the notifications lifecycle subsystem, when this
|
|
373
|
+
* process was spawned by `/session_create`. Gated by `GJC_LIFECYCLE_REQUEST_ID`
|
|
374
|
+
* so it ONLY applies to lifecycle-launched sessions (never normal launches): the
|
|
375
|
+
* daemon tags the tmux session, endpoint discovery, and its `/session_recent`
|
|
376
|
+
* id with this value, so the agent MUST adopt it as its header id or those ids
|
|
377
|
+
* diverge (breaking close/resume-by-id after the session is gone).
|
|
378
|
+
*/
|
|
379
|
+
function lifecyclePreallocatedSessionId(): string | undefined {
|
|
380
|
+
if (!process.env.GJC_LIFECYCLE_REQUEST_ID) return undefined;
|
|
381
|
+
const id = process.env.GJC_SESSION_ID?.trim();
|
|
382
|
+
if (!id || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) return undefined;
|
|
383
|
+
return id;
|
|
384
|
+
}
|
|
385
|
+
|
|
322
386
|
/** Generate a unique short ID (8 hex chars, collision-checked) */
|
|
323
387
|
function generateId(byId: { has(id: string): boolean }): string {
|
|
324
388
|
for (let i = 0; i < 100; i++) {
|
|
@@ -576,8 +640,11 @@ export function buildSessionContext(
|
|
|
576
640
|
|
|
577
641
|
// Walk from leaf to root, then reverse once to avoid repeated front insertions on long branches.
|
|
578
642
|
const path: SessionEntry[] = [];
|
|
643
|
+
const visited = new Set<string>();
|
|
579
644
|
let current: SessionEntry | undefined = leaf;
|
|
580
645
|
while (current) {
|
|
646
|
+
if (visited.has(current.id)) break;
|
|
647
|
+
visited.add(current.id);
|
|
581
648
|
path.push(current);
|
|
582
649
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
583
650
|
}
|
|
@@ -1199,6 +1266,17 @@ function isResidentBlobSentinel(value: unknown): value is ResidentBlobSentinel {
|
|
|
1199
1266
|
isBlobRef((value as { ref: string }).ref)
|
|
1200
1267
|
);
|
|
1201
1268
|
}
|
|
1269
|
+
function containsResidentSentinel(value: unknown, seen = new WeakSet<object>()): boolean {
|
|
1270
|
+
if (value === null || value === undefined || typeof value !== "object") return false;
|
|
1271
|
+
if ((value as { [RESIDENT_BLOB_SENTINEL_KEY]?: unknown })[RESIDENT_BLOB_SENTINEL_KEY] === true) return true;
|
|
1272
|
+
if (seen.has(value)) return false;
|
|
1273
|
+
seen.add(value);
|
|
1274
|
+
if (Array.isArray(value)) return value.some(item => containsResidentSentinel(item, seen));
|
|
1275
|
+
for (const child of Object.values(value)) {
|
|
1276
|
+
if (containsResidentSentinel(child, seen)) return true;
|
|
1277
|
+
}
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1202
1280
|
|
|
1203
1281
|
/**
|
|
1204
1282
|
* Recursively truncate large strings in an object for session persistence.
|
|
@@ -1257,6 +1335,7 @@ interface ResidentBlobStores {
|
|
|
1257
1335
|
imageStore: BlobStore;
|
|
1258
1336
|
sessionId?: string;
|
|
1259
1337
|
sessionFile?: string;
|
|
1338
|
+
onResidentBlobRead?: (kind: ResidentBlobKind) => void;
|
|
1260
1339
|
}
|
|
1261
1340
|
|
|
1262
1341
|
type ResidentBlobMissingPolicy = "throw" | "placeholder";
|
|
@@ -1344,6 +1423,7 @@ function materializeResidentValueSync(
|
|
|
1344
1423
|
}
|
|
1345
1424
|
}
|
|
1346
1425
|
cache.set(cacheKey, resolved);
|
|
1426
|
+
stores.onResidentBlobRead?.(obj.kind);
|
|
1347
1427
|
return resolved;
|
|
1348
1428
|
}
|
|
1349
1429
|
if (Array.isArray(obj)) {
|
|
@@ -1435,6 +1515,308 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
|
|
|
1435
1515
|
return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
|
|
1436
1516
|
}
|
|
1437
1517
|
|
|
1518
|
+
const COLD_SPILL_NOTICE = "[Compacted history content evicted to durable cold storage]";
|
|
1519
|
+
const COLD_SPILL_ARGUMENTS_SENTINEL_KEY = "__gjcColdSpillArguments";
|
|
1520
|
+
const COLD_SPILL_MIN_CHARS = 1024;
|
|
1521
|
+
|
|
1522
|
+
type ColdSpillWrite = {
|
|
1523
|
+
path: string;
|
|
1524
|
+
encoding: "utf8" | "json";
|
|
1525
|
+
data: Buffer;
|
|
1526
|
+
originalChars: number;
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
type ColdSpillResidentPromotion = {
|
|
1530
|
+
stores: ResidentBlobStores;
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1534
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function isColdSpillRef(value: unknown): value is ColdSpillRef {
|
|
1538
|
+
return (
|
|
1539
|
+
isRecord(value) &&
|
|
1540
|
+
value.kind === "cold_spill" &&
|
|
1541
|
+
typeof value.ref === "string" &&
|
|
1542
|
+
(value.encoding === "utf8" || value.encoding === "json") &&
|
|
1543
|
+
typeof value.originalChars === "number" &&
|
|
1544
|
+
typeof value.sha256 === "string" &&
|
|
1545
|
+
typeof value.bytes === "number"
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function isColdSpillArgumentsSentinel(value: unknown): value is Record<string, unknown> {
|
|
1550
|
+
return isRecord(value) && value[COLD_SPILL_ARGUMENTS_SENTINEL_KEY] === true;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function residentBlobBytesForColdSpill(value: ResidentBlobSentinel, promotion: ColdSpillResidentPromotion): Buffer {
|
|
1554
|
+
const hash = parseBlobRef(value.ref);
|
|
1555
|
+
if (!hash)
|
|
1556
|
+
throw new ResidentBlobMissingError(
|
|
1557
|
+
value.ref,
|
|
1558
|
+
value.kind,
|
|
1559
|
+
promotion.stores.sessionId,
|
|
1560
|
+
promotion.stores.sessionFile,
|
|
1561
|
+
);
|
|
1562
|
+
const store = value.kind === "text" ? promotion.stores.textStore : promotion.stores.imageStore;
|
|
1563
|
+
const data = store.getSync(hash);
|
|
1564
|
+
if (!data)
|
|
1565
|
+
throw new ResidentBlobMissingError(hash, value.kind, promotion.stores.sessionId, promotion.stores.sessionFile);
|
|
1566
|
+
promotion.stores.onResidentBlobRead?.(value.kind);
|
|
1567
|
+
if (value.kind === "imageData") return Buffer.from(data.toString("base64"), "utf8");
|
|
1568
|
+
return Buffer.from(data);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function coldSpillResidentValue(
|
|
1572
|
+
value: ResidentBlobSentinel,
|
|
1573
|
+
basePath: string,
|
|
1574
|
+
writes: ColdSpillWrite[],
|
|
1575
|
+
promotion: ColdSpillResidentPromotion,
|
|
1576
|
+
): string {
|
|
1577
|
+
const data = residentBlobBytesForColdSpill(value, promotion);
|
|
1578
|
+
writes.push({ path: basePath, encoding: "utf8", data, originalChars: data.byteLength });
|
|
1579
|
+
return COLD_SPILL_NOTICE;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function coldSpillTextValue(value: string, basePath: string, writes: ColdSpillWrite[]): string {
|
|
1583
|
+
writes.push({ path: basePath, encoding: "utf8", data: Buffer.from(value, "utf8"), originalChars: value.length });
|
|
1584
|
+
return COLD_SPILL_NOTICE;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function coldSpillJsonValue(value: unknown, basePath: string, writes: ColdSpillWrite[]): Record<string, unknown> {
|
|
1588
|
+
const json = JSON.stringify(value);
|
|
1589
|
+
writes.push({ path: basePath, encoding: "json", data: Buffer.from(json, "utf8"), originalChars: json.length });
|
|
1590
|
+
return {
|
|
1591
|
+
[COLD_SPILL_ARGUMENTS_SENTINEL_KEY]: true,
|
|
1592
|
+
refPath: basePath,
|
|
1593
|
+
notice: COLD_SPILL_NOTICE,
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function coldSpillSubtreeValue(
|
|
1598
|
+
value: unknown,
|
|
1599
|
+
basePath: string,
|
|
1600
|
+
writes: ColdSpillWrite[],
|
|
1601
|
+
promotion: ColdSpillResidentPromotion,
|
|
1602
|
+
): unknown {
|
|
1603
|
+
if (isResidentBlobSentinel(value)) return coldSpillResidentValue(value, basePath, writes, promotion);
|
|
1604
|
+
if (isColdSpillArgumentsSentinel(value)) return value;
|
|
1605
|
+
if (typeof value === "string") {
|
|
1606
|
+
return value.length >= COLD_SPILL_MIN_CHARS ? coldSpillTextValue(value, basePath, writes) : value;
|
|
1607
|
+
}
|
|
1608
|
+
if (Array.isArray(value)) {
|
|
1609
|
+
if (!containsResidentSentinel(value)) {
|
|
1610
|
+
const json = JSON.stringify(value);
|
|
1611
|
+
return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
|
|
1612
|
+
}
|
|
1613
|
+
let changed = false;
|
|
1614
|
+
const next = value.map((child, index) => {
|
|
1615
|
+
const replaced = coldSpillSubtreeValue(child, `${basePath}.${index}`, writes, promotion);
|
|
1616
|
+
if (replaced !== child) changed = true;
|
|
1617
|
+
return replaced;
|
|
1618
|
+
});
|
|
1619
|
+
return changed ? next : value;
|
|
1620
|
+
}
|
|
1621
|
+
if (!isRecord(value)) return value;
|
|
1622
|
+
if (!containsResidentSentinel(value)) {
|
|
1623
|
+
const json = JSON.stringify(value);
|
|
1624
|
+
return json.length >= COLD_SPILL_MIN_CHARS ? coldSpillJsonValue(value, basePath, writes) : value;
|
|
1625
|
+
}
|
|
1626
|
+
let changed = false;
|
|
1627
|
+
const entries = Object.entries(value).map(([key, child]) => {
|
|
1628
|
+
const replaced = coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
|
|
1629
|
+
if (replaced !== child) changed = true;
|
|
1630
|
+
return [key, replaced] as const;
|
|
1631
|
+
});
|
|
1632
|
+
return changed ? Object.fromEntries(entries) : value;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
function coldSpillArgumentsValue(
|
|
1636
|
+
value: unknown,
|
|
1637
|
+
basePath: string,
|
|
1638
|
+
writes: ColdSpillWrite[],
|
|
1639
|
+
promotion: ColdSpillResidentPromotion,
|
|
1640
|
+
): unknown {
|
|
1641
|
+
return coldSpillSubtreeValue(value, basePath, writes, promotion);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function coldSpillContentBlock(
|
|
1645
|
+
block: unknown,
|
|
1646
|
+
basePath: string,
|
|
1647
|
+
writes: ColdSpillWrite[],
|
|
1648
|
+
promotion: ColdSpillResidentPromotion,
|
|
1649
|
+
): unknown {
|
|
1650
|
+
if (!isRecord(block) || typeof block.type !== "string") return block;
|
|
1651
|
+
if (isResidentBlobSentinel(block)) return coldSpillResidentValue(block, basePath, writes, promotion);
|
|
1652
|
+
if (block.type === "image") return block;
|
|
1653
|
+
if (block.type === "text") {
|
|
1654
|
+
const text = block.text;
|
|
1655
|
+
if (isResidentBlobSentinel(text))
|
|
1656
|
+
return { ...block, text: coldSpillResidentValue(text, `${basePath}.text`, writes, promotion) };
|
|
1657
|
+
if (typeof text !== "string" || text.length < COLD_SPILL_MIN_CHARS) return block;
|
|
1658
|
+
return { ...block, text: coldSpillTextValue(text, `${basePath}.text`, writes) };
|
|
1659
|
+
}
|
|
1660
|
+
if (block.type === "thinking") {
|
|
1661
|
+
const thinking = block.thinking;
|
|
1662
|
+
if (typeof thinking !== "string" || thinking.length < COLD_SPILL_MIN_CHARS) return block;
|
|
1663
|
+
return { ...block, thinking: coldSpillTextValue(thinking, `${basePath}.thinking`, writes) };
|
|
1664
|
+
}
|
|
1665
|
+
if (block.type === "redactedThinking") {
|
|
1666
|
+
const data = block.data;
|
|
1667
|
+
if (typeof data !== "string" || data.length < COLD_SPILL_MIN_CHARS) return block;
|
|
1668
|
+
return { ...block, data: coldSpillTextValue(data, `${basePath}.data`, writes) };
|
|
1669
|
+
}
|
|
1670
|
+
if (block.type === "toolCall") {
|
|
1671
|
+
const args = block.arguments;
|
|
1672
|
+
if (isColdSpillArgumentsSentinel(args)) return block;
|
|
1673
|
+
const json = JSON.stringify(args);
|
|
1674
|
+
if (json.length < COLD_SPILL_MIN_CHARS && !containsResidentSentinel(args)) return block;
|
|
1675
|
+
const nextArgs = coldSpillArgumentsValue(args, `${basePath}.arguments`, writes, promotion);
|
|
1676
|
+
return nextArgs === args ? block : { ...block, arguments: nextArgs };
|
|
1677
|
+
}
|
|
1678
|
+
let changed = false;
|
|
1679
|
+
const entries = Object.entries(block).map(([key, child]) => {
|
|
1680
|
+
const replaced = key === "type" ? child : coldSpillSubtreeValue(child, `${basePath}.${key}`, writes, promotion);
|
|
1681
|
+
if (replaced !== child) changed = true;
|
|
1682
|
+
return [key, replaced] as const;
|
|
1683
|
+
});
|
|
1684
|
+
return changed ? Object.fromEntries(entries) : block;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function coldSpillContentBlocks(
|
|
1688
|
+
value: unknown[],
|
|
1689
|
+
basePath: string,
|
|
1690
|
+
writes: ColdSpillWrite[],
|
|
1691
|
+
promotion: ColdSpillResidentPromotion,
|
|
1692
|
+
): unknown {
|
|
1693
|
+
if (!containsResidentSentinel(value)) {
|
|
1694
|
+
let changedRuns = false;
|
|
1695
|
+
const merged: unknown[] = [];
|
|
1696
|
+
for (let index = 0; index < value.length; index++) {
|
|
1697
|
+
const block = value[index];
|
|
1698
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
|
|
1699
|
+
const start = index;
|
|
1700
|
+
const texts: string[] = [];
|
|
1701
|
+
while (index < value.length) {
|
|
1702
|
+
const runBlock = value[index];
|
|
1703
|
+
if (!isRecord(runBlock) || runBlock.type !== "text" || typeof runBlock.text !== "string") break;
|
|
1704
|
+
texts.push(runBlock.text);
|
|
1705
|
+
index++;
|
|
1706
|
+
}
|
|
1707
|
+
index--;
|
|
1708
|
+
const text = texts.join("");
|
|
1709
|
+
if (text.length >= COLD_SPILL_MIN_CHARS) {
|
|
1710
|
+
changedRuns = true;
|
|
1711
|
+
merged.push({ ...block, text: coldSpillTextValue(text, `${basePath}.${start}.text`, writes) });
|
|
1712
|
+
} else {
|
|
1713
|
+
merged.push(...value.slice(start, index + 1));
|
|
1714
|
+
}
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
|
|
1718
|
+
if (replaced !== block) changedRuns = true;
|
|
1719
|
+
merged.push(replaced);
|
|
1720
|
+
}
|
|
1721
|
+
if (changedRuns) return merged;
|
|
1722
|
+
}
|
|
1723
|
+
let changed = false;
|
|
1724
|
+
const next = value.map((block, index) => {
|
|
1725
|
+
const replaced = coldSpillContentBlock(block, `${basePath}.${index}`, writes, promotion);
|
|
1726
|
+
if (replaced !== block) changed = true;
|
|
1727
|
+
return replaced;
|
|
1728
|
+
});
|
|
1729
|
+
return changed ? next : value;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function coldSpillCustomMessageContent(
|
|
1733
|
+
content: CustomMessageEntry["content"],
|
|
1734
|
+
writes: ColdSpillWrite[],
|
|
1735
|
+
promotion: ColdSpillResidentPromotion,
|
|
1736
|
+
): CustomMessageEntry["content"] {
|
|
1737
|
+
if (typeof content === "string") {
|
|
1738
|
+
return content.length >= COLD_SPILL_MIN_CHARS
|
|
1739
|
+
? coldSpillTextValue(content, "custom_message.content", writes)
|
|
1740
|
+
: content;
|
|
1741
|
+
}
|
|
1742
|
+
if (Array.isArray(content))
|
|
1743
|
+
return coldSpillContentBlocks(
|
|
1744
|
+
content,
|
|
1745
|
+
"custom_message.content",
|
|
1746
|
+
writes,
|
|
1747
|
+
promotion,
|
|
1748
|
+
) as CustomMessageEntry["content"];
|
|
1749
|
+
return content;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function coldSpillUnavailable(ref: ColdSpillRef): string {
|
|
1753
|
+
return `[Cold-spill blob unavailable: ${ref.ref}; original ${ref.originalChars} chars unavailable]`;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function rehydrateColdSpillRef(ref: ColdSpillRef, blobStore: BlobStore, residentStores?: ResidentBlobStores): unknown {
|
|
1757
|
+
const hash = ref.ref.startsWith("blob:sha256:") ? ref.ref.slice("blob:sha256:".length) : ref.sha256;
|
|
1758
|
+
const data = blobStore.getCheckedSync(hash);
|
|
1759
|
+
if (!data || hash !== ref.sha256) return coldSpillUnavailable(ref);
|
|
1760
|
+
const text = data.toString("utf8");
|
|
1761
|
+
if (ref.encoding === "json") {
|
|
1762
|
+
try {
|
|
1763
|
+
const parsed = JSON.parse(text) as unknown;
|
|
1764
|
+
return residentStores ? materializeResidentValueSync(parsed, residentStores) : parsed;
|
|
1765
|
+
} catch {
|
|
1766
|
+
return coldSpillUnavailable(ref);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
return text;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function rehydrateColdSpillValue(
|
|
1773
|
+
value: unknown,
|
|
1774
|
+
marker: EvictedContentMarker | undefined,
|
|
1775
|
+
blobStore: BlobStore,
|
|
1776
|
+
basePath: string,
|
|
1777
|
+
residentStores?: ResidentBlobStores,
|
|
1778
|
+
): unknown {
|
|
1779
|
+
const directRef = marker?.payloads[basePath];
|
|
1780
|
+
if (directRef) return rehydrateColdSpillRef(directRef, blobStore, residentStores);
|
|
1781
|
+
if (isColdSpillArgumentsSentinel(value) && typeof value.refPath === "string") {
|
|
1782
|
+
const ref = marker?.payloads[value.refPath];
|
|
1783
|
+
return ref ? rehydrateColdSpillRef(ref, blobStore, residentStores) : value;
|
|
1784
|
+
}
|
|
1785
|
+
if (Array.isArray(value))
|
|
1786
|
+
return value.map((item, index) =>
|
|
1787
|
+
rehydrateColdSpillValue(item, marker, blobStore, `${basePath}.${index}`, residentStores),
|
|
1788
|
+
);
|
|
1789
|
+
if (!isRecord(value)) return value;
|
|
1790
|
+
const entries = Object.entries(value).map(([key, child]) => {
|
|
1791
|
+
if (key === "evictedContent") return [key, child] as const;
|
|
1792
|
+
return [key, rehydrateColdSpillValue(child, marker, blobStore, `${basePath}.${key}`, residentStores)] as const;
|
|
1793
|
+
});
|
|
1794
|
+
return Object.fromEntries(entries);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function rehydrateColdSpillEntry(
|
|
1798
|
+
entry: SessionEntry,
|
|
1799
|
+
blobStore: BlobStore,
|
|
1800
|
+
residentStores?: ResidentBlobStores,
|
|
1801
|
+
): SessionEntry {
|
|
1802
|
+
if (entry.type === "message") {
|
|
1803
|
+
const marker = entry.evictedContent;
|
|
1804
|
+
const message = rehydrateColdSpillValue(
|
|
1805
|
+
entry.message,
|
|
1806
|
+
marker,
|
|
1807
|
+
blobStore,
|
|
1808
|
+
"message",
|
|
1809
|
+
residentStores,
|
|
1810
|
+
) as AgentMessage;
|
|
1811
|
+
return { ...entry, message };
|
|
1812
|
+
}
|
|
1813
|
+
if (entry.type === "custom_message") {
|
|
1814
|
+
const marker = entry.evictedContent;
|
|
1815
|
+
return rehydrateColdSpillValue(entry, marker, blobStore, "custom_message", residentStores) as CustomMessageEntry;
|
|
1816
|
+
}
|
|
1817
|
+
return cloneSessionEntry(entry);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1438
1820
|
async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
|
|
1439
1821
|
async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
|
|
1440
1822
|
async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
|
|
@@ -2171,6 +2553,8 @@ interface SessionManagerStateSnapshot {
|
|
|
2171
2553
|
|
|
2172
2554
|
export class SessionManager {
|
|
2173
2555
|
#sessionId: string = "";
|
|
2556
|
+
/** True once a lifecycle pre-allocated id has been adopted (consume-once). */
|
|
2557
|
+
#lifecycleIdAdopted: boolean = false;
|
|
2174
2558
|
#sessionName: string | undefined;
|
|
2175
2559
|
#titleSource: "auto" | "user" | undefined;
|
|
2176
2560
|
#sessionFile: string | undefined;
|
|
@@ -2220,6 +2604,16 @@ export class SessionManager {
|
|
|
2220
2604
|
#sessionContextEntryRevision = -1;
|
|
2221
2605
|
#sessionContextLeafRevision = -1;
|
|
2222
2606
|
#sessionContextReplayMetadataRevision = -1;
|
|
2607
|
+
#coldSpillWriteCount = 0;
|
|
2608
|
+
#coldSpillReadCount = 0;
|
|
2609
|
+
#residentTextReadCount = 0;
|
|
2610
|
+
#residentImageReadCount = 0;
|
|
2611
|
+
#publicMaterializerCallCount = 0;
|
|
2612
|
+
#getEntryMaterializerCallCount = 0;
|
|
2613
|
+
#getBranchMaterializerCallCount = 0;
|
|
2614
|
+
#getEntriesMaterializerCallCount = 0;
|
|
2615
|
+
#materializedEntriesCachePopulateCount = 0;
|
|
2616
|
+
#pathOnlyContextBuildCount = 0;
|
|
2223
2617
|
|
|
2224
2618
|
private constructor(
|
|
2225
2619
|
private cwd: string,
|
|
@@ -2244,6 +2638,19 @@ export class SessionManager {
|
|
|
2244
2638
|
};
|
|
2245
2639
|
}
|
|
2246
2640
|
|
|
2641
|
+
#residentBlobStoresForColdRehydrate(): ResidentBlobStores {
|
|
2642
|
+
return {
|
|
2643
|
+
...this.#residentBlobStores(),
|
|
2644
|
+
onResidentBlobRead: kind => {
|
|
2645
|
+
if (kind === "text") {
|
|
2646
|
+
this.#residentTextReadCount++;
|
|
2647
|
+
} else {
|
|
2648
|
+
this.#residentImageReadCount++;
|
|
2649
|
+
}
|
|
2650
|
+
},
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2247
2654
|
#residentCacheDir(sessionFile: string): string {
|
|
2248
2655
|
const instance = ++residentCacheInstanceCounter;
|
|
2249
2656
|
return path.join(
|
|
@@ -2615,7 +3022,12 @@ export class SessionManager {
|
|
|
2615
3022
|
this.#persistChain = Promise.resolve();
|
|
2616
3023
|
this.#persistError = undefined;
|
|
2617
3024
|
this.#persistErrorReported = false;
|
|
2618
|
-
|
|
3025
|
+
// Adopt a lifecycle pre-allocated id exactly once (the initial session of a
|
|
3026
|
+
// /session_create child); later new-session paths (/new, fork, branch) get
|
|
3027
|
+
// fresh ids so they cannot reuse the original GJC_SESSION_ID.
|
|
3028
|
+
const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
|
|
3029
|
+
if (preallocated) this.#lifecycleIdAdopted = true;
|
|
3030
|
+
this.#sessionId = preallocated ?? createSessionId();
|
|
2619
3031
|
this.#sessionName = undefined;
|
|
2620
3032
|
this.#titleSource = undefined;
|
|
2621
3033
|
const timestamp = new Date().toISOString();
|
|
@@ -3579,8 +3991,11 @@ export class SessionManager {
|
|
|
3579
3991
|
* Returns undefined if no model change has been recorded.
|
|
3580
3992
|
*/
|
|
3581
3993
|
getLastModelChangeRole(): string | undefined {
|
|
3994
|
+
const visited = new Set<string>();
|
|
3582
3995
|
let current = this.getLeafEntry();
|
|
3583
3996
|
while (current) {
|
|
3997
|
+
if (visited.has(current.id)) break;
|
|
3998
|
+
visited.add(current.id);
|
|
3584
3999
|
if (current.type === "model_change") {
|
|
3585
4000
|
return current.role ?? "default";
|
|
3586
4001
|
}
|
|
@@ -3589,7 +4004,223 @@ export class SessionManager {
|
|
|
3589
4004
|
return undefined;
|
|
3590
4005
|
}
|
|
3591
4006
|
|
|
4007
|
+
evictCompactedContent(firstKeptEntryId: string, compactionEntryId: string): EvictCompactedContentResult {
|
|
4008
|
+
const firstKept = this.#byId.get(firstKeptEntryId);
|
|
4009
|
+
const compaction = this.#byId.get(compactionEntryId);
|
|
4010
|
+
if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
|
|
4011
|
+
if (!compaction || compaction.type !== "compaction")
|
|
4012
|
+
throw new Error(`Compaction entry ${compactionEntryId} not found`);
|
|
4013
|
+
const ids: string[] = [];
|
|
4014
|
+
const visited = new Set<string>();
|
|
4015
|
+
let current: SessionEntry | undefined = compaction;
|
|
4016
|
+
while (current) {
|
|
4017
|
+
if (visited.has(current.id)) break;
|
|
4018
|
+
visited.add(current.id);
|
|
4019
|
+
ids.push(current.id);
|
|
4020
|
+
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4021
|
+
}
|
|
4022
|
+
ids.reverse();
|
|
4023
|
+
let evictedEntries = 0;
|
|
4024
|
+
let hotCharsRemoved = 0;
|
|
4025
|
+
let coldBlobBytes = 0;
|
|
4026
|
+
let payloadRefs = 0;
|
|
4027
|
+
let alreadyEvictedEntries = 0;
|
|
4028
|
+
let mutated = false;
|
|
4029
|
+
try {
|
|
4030
|
+
for (const id of ids) {
|
|
4031
|
+
if (id === firstKeptEntryId) break;
|
|
4032
|
+
const entry = this.#byId.get(id);
|
|
4033
|
+
if (!entry || entry.type === "compaction") continue;
|
|
4034
|
+
if (entry.type !== "message" && entry.type !== "custom_message") continue;
|
|
4035
|
+
if (entry.evictedContent?.reason === "compacted_history") {
|
|
4036
|
+
alreadyEvictedEntries++;
|
|
4037
|
+
continue;
|
|
4038
|
+
}
|
|
4039
|
+
const beforeChars = JSON.stringify(entry).length;
|
|
4040
|
+
const writes: ColdSpillWrite[] = [];
|
|
4041
|
+
const nextEntry = this.#coldSpillClone(entry, writes);
|
|
4042
|
+
if (writes.length === 0 || nextEntry === entry) continue;
|
|
4043
|
+
const payloads: Record<string, ColdSpillRef> = {};
|
|
4044
|
+
for (const write of writes) {
|
|
4045
|
+
const put = this.#blobStore.putImmutableSync(write.data);
|
|
4046
|
+
this.#coldSpillWriteCount++;
|
|
4047
|
+
payloads[write.path] = {
|
|
4048
|
+
kind: "cold_spill",
|
|
4049
|
+
ref: put.ref,
|
|
4050
|
+
encoding: write.encoding,
|
|
4051
|
+
originalChars: write.originalChars,
|
|
4052
|
+
sha256: put.hash,
|
|
4053
|
+
bytes: put.bytes,
|
|
4054
|
+
};
|
|
4055
|
+
coldBlobBytes += put.bytes;
|
|
4056
|
+
}
|
|
4057
|
+
const marker: EvictedContentMarker = {
|
|
4058
|
+
evictedAt: Date.now(),
|
|
4059
|
+
reason: "compacted_history",
|
|
4060
|
+
compactionEntryId,
|
|
4061
|
+
firstKeptEntryId,
|
|
4062
|
+
payloads,
|
|
4063
|
+
};
|
|
4064
|
+
// Store the marker at the ENTRY level (session metadata), not on the
|
|
4065
|
+
// strict message type, so message shapes stay type-clean.
|
|
4066
|
+
if (nextEntry.type === "message" || nextEntry.type === "custom_message") {
|
|
4067
|
+
nextEntry.evictedContent = marker;
|
|
4068
|
+
}
|
|
4069
|
+
this.#replaceCanonicalEntry(nextEntry);
|
|
4070
|
+
mutated = true;
|
|
4071
|
+
evictedEntries++;
|
|
4072
|
+
payloadRefs += writes.length;
|
|
4073
|
+
hotCharsRemoved += Math.max(0, beforeChars - JSON.stringify(nextEntry).length);
|
|
4074
|
+
}
|
|
4075
|
+
} finally {
|
|
4076
|
+
if (mutated) {
|
|
4077
|
+
this.#needsFullRewriteOnNextPersist = true;
|
|
4078
|
+
this.#bumpEntryRevision();
|
|
4079
|
+
this.#replayMetadataRevision++;
|
|
4080
|
+
this.#materializedEntriesCache = undefined;
|
|
4081
|
+
this.#materializedEntriesRevision = -1;
|
|
4082
|
+
this.#sessionContextCache = undefined;
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
return {
|
|
4086
|
+
evictedEntries,
|
|
4087
|
+
hotCharsRemoved,
|
|
4088
|
+
coldBlobBytes,
|
|
4089
|
+
payloadRefs,
|
|
4090
|
+
alreadyEvictedEntries,
|
|
4091
|
+
coldSpillWriteCount: this.#coldSpillWriteCount,
|
|
4092
|
+
coldSpillReadCount: this.#coldSpillReadCount,
|
|
4093
|
+
residentTextReadCount: this.#residentTextReadCount,
|
|
4094
|
+
residentImageReadCount: this.#residentImageReadCount,
|
|
4095
|
+
};
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
#coldSpillClone(entry: SessionEntry, writes: ColdSpillWrite[]): SessionEntry {
|
|
4099
|
+
if (entry.type === "message") {
|
|
4100
|
+
const content = "content" in entry.message ? entry.message.content : undefined;
|
|
4101
|
+
if (!Array.isArray(content)) return entry;
|
|
4102
|
+
const nextContent = coldSpillContentBlocks(content, "message.content", writes, {
|
|
4103
|
+
stores: this.#residentBlobStoresForColdRehydrate(),
|
|
4104
|
+
});
|
|
4105
|
+
return nextContent === content
|
|
4106
|
+
? entry
|
|
4107
|
+
: { ...entry, message: { ...entry.message, content: nextContent } as AgentMessage };
|
|
4108
|
+
}
|
|
4109
|
+
if (entry.type === "custom_message") {
|
|
4110
|
+
const content = coldSpillCustomMessageContent(entry.content, writes, {
|
|
4111
|
+
stores: this.#residentBlobStoresForColdRehydrate(),
|
|
4112
|
+
});
|
|
4113
|
+
return content === entry.content ? entry : { ...entry, content };
|
|
4114
|
+
}
|
|
4115
|
+
return entry;
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
#replaceCanonicalEntry(entry: SessionEntry): void {
|
|
4119
|
+
this.#byId.set(entry.id, entry);
|
|
4120
|
+
const index = this.#fileEntries.findIndex(candidate => candidate.type !== "session" && candidate.id === entry.id);
|
|
4121
|
+
if (index >= 0) this.#fileEntries[index] = entry;
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
getObservabilityStatsForTests(): SessionManagerObservabilityStats {
|
|
4125
|
+
return {
|
|
4126
|
+
coldSpillWriteCount: this.#coldSpillWriteCount,
|
|
4127
|
+
coldSpillReadCount: this.#coldSpillReadCount,
|
|
4128
|
+
residentTextReadCount: this.#residentTextReadCount,
|
|
4129
|
+
residentImageReadCount: this.#residentImageReadCount,
|
|
4130
|
+
publicMaterializerCallCount: this.#publicMaterializerCallCount,
|
|
4131
|
+
getEntryMaterializerCallCount: this.#getEntryMaterializerCallCount,
|
|
4132
|
+
getBranchMaterializerCallCount: this.#getBranchMaterializerCallCount,
|
|
4133
|
+
getEntriesMaterializerCallCount: this.#getEntriesMaterializerCallCount,
|
|
4134
|
+
materializedEntriesCachePopulateCount: this.#materializedEntriesCachePopulateCount,
|
|
4135
|
+
pathOnlyContextBuildCount: this.#pathOnlyContextBuildCount,
|
|
4136
|
+
};
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
hotRetainedMessageCharsForTests(): number {
|
|
4140
|
+
let total = 0;
|
|
4141
|
+
for (const entry of this.#fileEntries) {
|
|
4142
|
+
if (entry.type !== "message" && entry.type !== "custom_message") continue;
|
|
4143
|
+
total += JSON.stringify(entry).length;
|
|
4144
|
+
}
|
|
4145
|
+
return total;
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
getCanonicalEntryForTests(id: string): SessionEntry | undefined {
|
|
4149
|
+
const entry = this.#byId.get(id);
|
|
4150
|
+
return entry ? cloneSessionEntry(entry) : undefined;
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
getEntryForFidelity(id: string): SessionEntry | undefined {
|
|
4154
|
+
const entry = this.#byId.get(id);
|
|
4155
|
+
return entry
|
|
4156
|
+
? rehydrateColdSpillEntry(
|
|
4157
|
+
materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()),
|
|
4158
|
+
this.#blobStore,
|
|
4159
|
+
this.#residentBlobStoresForColdRehydrate(),
|
|
4160
|
+
)
|
|
4161
|
+
: undefined;
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
getBranchForFidelity(fromId?: string): SessionEntry[] {
|
|
4165
|
+
const cache = new Map<string, string>();
|
|
4166
|
+
const path: SessionEntry[] = [];
|
|
4167
|
+
const visited = new Set<string>();
|
|
4168
|
+
let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
|
|
4169
|
+
while (current) {
|
|
4170
|
+
if (visited.has(current.id)) break;
|
|
4171
|
+
visited.add(current.id);
|
|
4172
|
+
path.push(
|
|
4173
|
+
rehydrateColdSpillEntry(
|
|
4174
|
+
materializeResidentEntrySync(current, this.#residentBlobStores(), cache),
|
|
4175
|
+
this.#blobStore,
|
|
4176
|
+
this.#residentBlobStoresForColdRehydrate(),
|
|
4177
|
+
),
|
|
4178
|
+
);
|
|
4179
|
+
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4180
|
+
}
|
|
4181
|
+
path.reverse();
|
|
4182
|
+
return path;
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
#getCanonicalBranchClones(fromId?: string): SessionEntry[] {
|
|
4186
|
+
const path: SessionEntry[] = [];
|
|
4187
|
+
const visited = new Set<string>();
|
|
4188
|
+
let current = (fromId ?? this.#leafId) ? this.#byId.get(fromId ?? this.#leafId ?? "") : undefined;
|
|
4189
|
+
while (current) {
|
|
4190
|
+
if (visited.has(current.id)) break;
|
|
4191
|
+
visited.add(current.id);
|
|
4192
|
+
path.push(cloneSessionEntry(current));
|
|
4193
|
+
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4194
|
+
}
|
|
4195
|
+
path.reverse();
|
|
4196
|
+
return path;
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
/**
|
|
4200
|
+
* Walk the active branch without materializing resident blobs or rehydrating
|
|
4201
|
+
* cold-spill payloads. Intended for metadata-only scans such as todo-phase
|
|
4202
|
+
* sync; callers must not mutate returned entries.
|
|
4203
|
+
*/
|
|
4204
|
+
getActivePathEntriesCanonical(fromId?: string): SessionEntry[] {
|
|
4205
|
+
return this.#getCanonicalBranchClones(fromId);
|
|
4206
|
+
}
|
|
4207
|
+
|
|
4208
|
+
getEntriesForExport(): SessionEntry[] {
|
|
4209
|
+
const cache = new Map<string, string>();
|
|
4210
|
+
return this.#fileEntries
|
|
4211
|
+
.filter((entry): entry is SessionEntry => entry.type !== "session")
|
|
4212
|
+
.map(entry =>
|
|
4213
|
+
rehydrateColdSpillEntry(
|
|
4214
|
+
materializeResidentEntrySync(entry, this.#residentBlobStores(), cache),
|
|
4215
|
+
this.#blobStore,
|
|
4216
|
+
this.#residentBlobStoresForColdRehydrate(),
|
|
4217
|
+
),
|
|
4218
|
+
);
|
|
4219
|
+
}
|
|
4220
|
+
|
|
3592
4221
|
getEntry(id: string): SessionEntry | undefined {
|
|
4222
|
+
this.#publicMaterializerCallCount++;
|
|
4223
|
+
this.#getEntryMaterializerCallCount++;
|
|
3593
4224
|
const entry = this.#byId.get(id);
|
|
3594
4225
|
return entry ? materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map()) : undefined;
|
|
3595
4226
|
}
|
|
@@ -3647,11 +4278,16 @@ export class SessionManager {
|
|
|
3647
4278
|
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
3648
4279
|
*/
|
|
3649
4280
|
getBranch(fromId?: string): SessionEntry[] {
|
|
4281
|
+
this.#publicMaterializerCallCount++;
|
|
4282
|
+
this.#getBranchMaterializerCallCount++;
|
|
3650
4283
|
const cache = new Map<string, string>();
|
|
3651
4284
|
const path: SessionEntry[] = [];
|
|
4285
|
+
const visited = new Set<string>();
|
|
3652
4286
|
const startId = fromId ?? this.#leafId;
|
|
3653
4287
|
let current = startId ? this.#byId.get(startId) : undefined;
|
|
3654
4288
|
while (current) {
|
|
4289
|
+
if (visited.has(current.id)) break;
|
|
4290
|
+
visited.add(current.id);
|
|
3655
4291
|
path.push(materializeResidentEntrySync(current, this.#residentBlobStores(), cache));
|
|
3656
4292
|
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
3657
4293
|
}
|
|
@@ -3673,13 +4309,63 @@ export class SessionManager {
|
|
|
3673
4309
|
) {
|
|
3674
4310
|
return cloneSessionContext(cached);
|
|
3675
4311
|
}
|
|
3676
|
-
|
|
4312
|
+
this.#pathOnlyContextBuildCount++;
|
|
4313
|
+
const context = buildSessionContext(this.#getActivePathEntriesForProviderContext(), this.#leafId);
|
|
3677
4314
|
this.#sessionContextCache = new WeakRef(context);
|
|
3678
4315
|
this.#sessionContextEntryRevision = this.#entryRevision;
|
|
3679
4316
|
this.#sessionContextLeafRevision = this.#leafRevision;
|
|
3680
4317
|
this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
|
|
3681
4318
|
return cloneSessionContext(context);
|
|
3682
4319
|
}
|
|
4320
|
+
|
|
4321
|
+
#getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
|
|
4322
|
+
if (fromId === null || (fromId === undefined && this.#leafId === null)) return [];
|
|
4323
|
+
const ids: string[] = [];
|
|
4324
|
+
const visited = new Set<string>();
|
|
4325
|
+
let current = this.#byId.get(fromId ?? this.#leafId ?? "");
|
|
4326
|
+
while (current) {
|
|
4327
|
+
if (visited.has(current.id)) break;
|
|
4328
|
+
visited.add(current.id);
|
|
4329
|
+
ids.push(current.id);
|
|
4330
|
+
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
4331
|
+
}
|
|
4332
|
+
ids.reverse();
|
|
4333
|
+
const pathEntries = ids
|
|
4334
|
+
.map(id => this.#byId.get(id))
|
|
4335
|
+
.filter((entry): entry is SessionEntry => entry !== undefined);
|
|
4336
|
+
let compaction: CompactionEntry | undefined;
|
|
4337
|
+
for (const entry of pathEntries) if (entry.type === "compaction") compaction = entry;
|
|
4338
|
+
if (!compaction) return pathEntries.map(entry => this.#entryForProviderContext(entry, undefined));
|
|
4339
|
+
const compactionIndex = pathEntries.findIndex(entry => entry.id === compaction.id);
|
|
4340
|
+
const firstKeptIndex = pathEntries.findIndex(entry => entry.id === compaction.firstKeptEntryId);
|
|
4341
|
+
const remote = compaction.preserveData?.openaiRemoteCompaction;
|
|
4342
|
+
const hasRemoteReplacement = isRecord(remote) && Array.isArray(remote.replacementHistory);
|
|
4343
|
+
return pathEntries.map((entry, index) => {
|
|
4344
|
+
const covered =
|
|
4345
|
+
index < compactionIndex && (hasRemoteReplacement || (firstKeptIndex >= 0 && index < firstKeptIndex));
|
|
4346
|
+
return this.#entryForProviderContext(entry, covered ? "covered" : undefined);
|
|
4347
|
+
});
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
#entryForProviderContext(entry: SessionEntry, coldSpillPolicy: "covered" | undefined): SessionEntry {
|
|
4351
|
+
if (coldSpillPolicy === "covered" && (entry.type === "message" || entry.type === "custom_message")) {
|
|
4352
|
+
return cloneSessionEntry(entry);
|
|
4353
|
+
}
|
|
4354
|
+
if (entry.type !== "message" && entry.type !== "custom_message") return cloneSessionEntry(entry);
|
|
4355
|
+
const materialized = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
|
|
4356
|
+
const rehydrated = rehydrateColdSpillEntry(
|
|
4357
|
+
materialized,
|
|
4358
|
+
this.#blobStore,
|
|
4359
|
+
this.#residentBlobStoresForColdRehydrate(),
|
|
4360
|
+
);
|
|
4361
|
+
if (rehydrated !== materialized) this.#coldSpillReadCount += this.#countColdSpillPayloads(entry);
|
|
4362
|
+
return rehydrated;
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
#countColdSpillPayloads(entry: SessionEntry): number {
|
|
4366
|
+
const marker = entry.type === "message" || entry.type === "custom_message" ? entry.evictedContent : undefined;
|
|
4367
|
+
return marker ? Object.keys(marker.payloads ?? {}).length : 0;
|
|
4368
|
+
}
|
|
3683
4369
|
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
|
|
3684
4370
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
3685
4371
|
let didSanitize = false;
|
|
@@ -3721,6 +4407,7 @@ export class SessionManager {
|
|
|
3721
4407
|
if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
|
|
3722
4408
|
return this.#materializedEntriesCache;
|
|
3723
4409
|
}
|
|
4410
|
+
this.#materializedEntriesCachePopulateCount++;
|
|
3724
4411
|
const resolvedTextBlobCache = new Map<string, string>();
|
|
3725
4412
|
const materializedEntries = this.#fileEntries
|
|
3726
4413
|
.filter((e): e is SessionEntry => e.type !== "session")
|
|
@@ -3731,6 +4418,8 @@ export class SessionManager {
|
|
|
3731
4418
|
}
|
|
3732
4419
|
|
|
3733
4420
|
getEntries(): SessionEntry[] {
|
|
4421
|
+
this.#publicMaterializerCallCount++;
|
|
4422
|
+
this.#getEntriesMaterializerCallCount++;
|
|
3734
4423
|
return this.#getMaterializedEntriesInternal().map(entry => cloneSessionEntry(entry));
|
|
3735
4424
|
}
|
|
3736
4425
|
|
|
@@ -3750,18 +4439,48 @@ export class SessionManager {
|
|
|
3750
4439
|
nodeMap.set(entry.id, { entry, children: [], label });
|
|
3751
4440
|
}
|
|
3752
4441
|
|
|
3753
|
-
|
|
4442
|
+
const addRoot = (node: SessionTreeNode): void => {
|
|
4443
|
+
if (!roots.includes(node)) {
|
|
4444
|
+
roots.push(node);
|
|
4445
|
+
}
|
|
4446
|
+
};
|
|
4447
|
+
const removeRoot = (node: SessionTreeNode): void => {
|
|
4448
|
+
const index = roots.indexOf(node);
|
|
4449
|
+
if (index !== -1) {
|
|
4450
|
+
roots.splice(index, 1);
|
|
4451
|
+
}
|
|
4452
|
+
};
|
|
4453
|
+
const wouldCreateChildCycle = (parent: SessionTreeNode, child: SessionTreeNode): boolean => {
|
|
4454
|
+
const stack: SessionTreeNode[] = [child];
|
|
4455
|
+
const visited = new Set<SessionTreeNode>();
|
|
4456
|
+
while (stack.length > 0) {
|
|
4457
|
+
const current = stack.pop()!;
|
|
4458
|
+
if (current === parent) {
|
|
4459
|
+
return true;
|
|
4460
|
+
}
|
|
4461
|
+
if (visited.has(current)) {
|
|
4462
|
+
continue;
|
|
4463
|
+
}
|
|
4464
|
+
visited.add(current);
|
|
4465
|
+
stack.push(...current.children);
|
|
4466
|
+
}
|
|
4467
|
+
return false;
|
|
4468
|
+
};
|
|
4469
|
+
|
|
4470
|
+
// Build tree. Corrupt session files can contain duplicate IDs or parentId
|
|
4471
|
+
// cycles; reject only the edge that would make the returned tree cyclic.
|
|
3754
4472
|
for (const entry of entries) {
|
|
3755
4473
|
const node = nodeMap.get(entry.id)!;
|
|
3756
4474
|
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
3757
|
-
|
|
4475
|
+
addRoot(node);
|
|
3758
4476
|
} else {
|
|
3759
4477
|
const parent = nodeMap.get(entry.parentId);
|
|
3760
|
-
if (parent) {
|
|
4478
|
+
if (parent && !wouldCreateChildCycle(parent, node)) {
|
|
3761
4479
|
parent.children.push(node);
|
|
4480
|
+
removeRoot(node);
|
|
3762
4481
|
} else {
|
|
3763
|
-
// Orphan - treat as root
|
|
3764
|
-
|
|
4482
|
+
// Orphan or cycle-closing edge - treat as root
|
|
4483
|
+
addRoot(node);
|
|
3765
4484
|
}
|
|
3766
4485
|
}
|
|
3767
4486
|
}
|
|
@@ -3769,8 +4488,13 @@ export class SessionManager {
|
|
|
3769
4488
|
// Sort children by timestamp (oldest first, newest at bottom)
|
|
3770
4489
|
// Use iterative approach to avoid stack overflow on deep trees
|
|
3771
4490
|
const stack: SessionTreeNode[] = [...roots];
|
|
4491
|
+
const sorted = new Set<SessionTreeNode>();
|
|
3772
4492
|
while (stack.length > 0) {
|
|
3773
4493
|
const node = stack.pop()!;
|
|
4494
|
+
if (sorted.has(node)) {
|
|
4495
|
+
continue;
|
|
4496
|
+
}
|
|
4497
|
+
sorted.add(node);
|
|
3774
4498
|
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
3775
4499
|
stack.push(...node.children);
|
|
3776
4500
|
}
|
|
@@ -3837,14 +4561,17 @@ export class SessionManager {
|
|
|
3837
4561
|
*/
|
|
3838
4562
|
createBranchedSession(leafId: string): string | undefined {
|
|
3839
4563
|
const previousSessionFile = this.#sessionFile;
|
|
3840
|
-
const branchPath = this
|
|
4564
|
+
const branchPath = this.#getCanonicalBranchClones(leafId);
|
|
3841
4565
|
if (branchPath.length === 0) {
|
|
3842
4566
|
throw new Error(`Entry ${leafId} not found`);
|
|
3843
4567
|
}
|
|
3844
4568
|
|
|
3845
4569
|
// Filter out LabelEntry from path - we'll recreate them from the resolved map
|
|
3846
4570
|
const pathWithoutLabels = branchPath.filter(e => e.type !== "label");
|
|
3847
|
-
|
|
4571
|
+
const materializedPathWithoutLabels = materializeResidentEntriesSync(
|
|
4572
|
+
pathWithoutLabels,
|
|
4573
|
+
this.#residentBlobStores(),
|
|
4574
|
+
);
|
|
3848
4575
|
const newSessionId = createSessionId();
|
|
3849
4576
|
const timestamp = new Date().toISOString();
|
|
3850
4577
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
@@ -3871,7 +4598,7 @@ export class SessionManager {
|
|
|
3871
4598
|
if (this.persist) {
|
|
3872
4599
|
const lines: string[] = [];
|
|
3873
4600
|
lines.push(JSON.stringify(header));
|
|
3874
|
-
for (const entry of
|
|
4601
|
+
for (const entry of materializedPathWithoutLabels) {
|
|
3875
4602
|
lines.push(JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore)));
|
|
3876
4603
|
}
|
|
3877
4604
|
// Write fresh label entries at the end
|
|
@@ -3898,7 +4625,7 @@ export class SessionManager {
|
|
|
3898
4625
|
this.#resetResidentTextBlobStore();
|
|
3899
4626
|
this.#fileEntries = [
|
|
3900
4627
|
header,
|
|
3901
|
-
...
|
|
4628
|
+
...materializedPathWithoutLabels.map(
|
|
3902
4629
|
entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
|
|
3903
4630
|
),
|
|
3904
4631
|
...labelEntries,
|
|
@@ -3928,7 +4655,7 @@ export class SessionManager {
|
|
|
3928
4655
|
this.#resetResidentTextBlobStore();
|
|
3929
4656
|
this.#fileEntries = [
|
|
3930
4657
|
header,
|
|
3931
|
-
...
|
|
4658
|
+
...materializedPathWithoutLabels.map(
|
|
3932
4659
|
entry => prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry,
|
|
3933
4660
|
),
|
|
3934
4661
|
...labelEntries,
|