@pi-vault/pi-dcp 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.
@@ -0,0 +1,149 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { ContextUsage, SessionState } from "../state/types.ts";
3
+ import type { DcpConfig } from "../config.ts";
4
+ import { formatMessageRef, formatMessageIdTag } from "../utils/message-ids.ts";
5
+ import type { PriorityMap } from "./priority.ts";
6
+ import { appendText } from "../utils/message-content.ts";
7
+ import {
8
+ CONTEXT_LIMIT_NUDGE,
9
+ TURN_NUDGE,
10
+ ITERATION_NUDGE,
11
+ } from "../prompts/nudges.ts";
12
+
13
+ /**
14
+ * Assign sequential message refs (m0001, m0002, ...) to messages.
15
+ * Refs are cached in state.messageIds.byIndex so re-runs don't reallocate.
16
+ * Also maintains a reverse map (byRef) for O(1) ref-to-index resolution.
17
+ */
18
+ export function assignMessageRefs(
19
+ state: SessionState,
20
+ messages: AgentMessage[],
21
+ ): void {
22
+ for (let i = 0; i < messages.length; i++) {
23
+ if (state.messageIds.byIndex.has(i)) continue;
24
+
25
+ const ref = formatMessageRef(state.messageIds.nextRefIndex);
26
+ state.messageIds.byIndex.set(i, ref);
27
+ state.messageIds.byRef.set(ref, i);
28
+ state.messageIds.nextRefIndex++;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Inject <dcp-message-id> tags into message text content.
34
+ * Returns a new array. Idempotent: skips if tag is already present.
35
+ *
36
+ * Handles both array content and plain-string content (E9: UserMessage.content
37
+ * can be a plain string — normalize to array form before injecting).
38
+ */
39
+ export function injectMessageIds(
40
+ state: SessionState,
41
+ messages: AgentMessage[],
42
+ priorityMap?: PriorityMap,
43
+ ): AgentMessage[] {
44
+ return messages.map((msg, i) => {
45
+ const ref = state.messageIds.byIndex.get(i);
46
+ if (!ref) return msg;
47
+
48
+ if (msg.role !== "user" && msg.role !== "assistant") return msg;
49
+
50
+ const priorityEntry = priorityMap?.get(i);
51
+ const tag = formatMessageIdTag(
52
+ ref,
53
+ priorityEntry ? { priority: priorityEntry.priority } : undefined,
54
+ );
55
+
56
+ // Idempotency marker uses "<dcp-message-id" (no closing >) to match both
57
+ // plain and priority-attribute variants.
58
+ return appendText(msg, `\n\n${tag}`, "<dcp-message-id");
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Inject compress nudges into messages based on context usage.
64
+ * Three tiers:
65
+ * - Context limit nudge: percent >= maxContextPercent (urgent)
66
+ * - Turn nudge: percent >= minContextPercent and last message is a user message
67
+ * - Iteration nudge: percent >= minContextPercent and many consecutive assistant messages
68
+ *
69
+ * Nudge text is appended to the last message that has text content.
70
+ * Returns a new array. Idempotent: skips if <dcp-system-reminder> is already present.
71
+ *
72
+ * Handles plain-string user message content (E9).
73
+ *
74
+ * NOTE: config.compress.nudgeFrequency and state.nudges anchor tracking are
75
+ * reserved for Phase 4+ throttling — not yet implemented.
76
+ */
77
+ export function injectCompressNudges(
78
+ state: SessionState,
79
+ config: DcpConfig,
80
+ messages: AgentMessage[],
81
+ contextUsage: ContextUsage | undefined,
82
+ ): AgentMessage[] {
83
+ if (!contextUsage) return messages;
84
+
85
+ // E5: percent can be null when unknown
86
+ if (contextUsage.percent == null) return messages;
87
+
88
+ const percent = contextUsage.percent;
89
+ const overMax = percent >= config.compress.maxContextPercent;
90
+ const overMin = percent >= config.compress.minContextPercent;
91
+
92
+ if (!overMin) return messages;
93
+
94
+ if (state.manualMode) return messages;
95
+ if (state.compressPermission === "deny") return messages;
96
+
97
+ if (messages.length === 0) return messages;
98
+
99
+ let nudgeText: string | undefined;
100
+
101
+ if (overMax) {
102
+ nudgeText = CONTEXT_LIMIT_NUDGE;
103
+ } else {
104
+ const lastMsg = messages[messages.length - 1];
105
+ if (lastMsg.role === "user") {
106
+ nudgeText = TURN_NUDGE;
107
+ } else {
108
+ // Count only assistant messages since the last user message
109
+ let messagesSinceUser = 0;
110
+ for (let i = messages.length - 1; i >= 0; i--) {
111
+ if (messages[i].role === "user") break;
112
+ if (messages[i].role === "assistant") messagesSinceUser++;
113
+ }
114
+ if (messagesSinceUser >= config.compress.iterationNudgeThreshold) {
115
+ nudgeText = ITERATION_NUDGE;
116
+ }
117
+ }
118
+ }
119
+
120
+ if (!nudgeText) return messages;
121
+
122
+ // Inject into the last message that has text content
123
+ const result = [...messages];
124
+ for (let i = result.length - 1; i >= 0; i--) {
125
+ const msg = result[i];
126
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
127
+
128
+ // Check for existing marker — stop searching if found
129
+ if (typeof msg.content === "string") {
130
+ if (msg.content.includes("<dcp-system-reminder>")) break;
131
+ } else if (Array.isArray(msg.content)) {
132
+ const tp = msg.content.find(
133
+ (p) =>
134
+ typeof p === "object" &&
135
+ p !== null &&
136
+ (p as unknown as Record<string, unknown>).type === "text",
137
+ ) as unknown as { text: string } | undefined;
138
+ if (!tp) continue;
139
+ if (tp.text.includes("<dcp-system-reminder>")) break;
140
+ } else {
141
+ continue;
142
+ }
143
+
144
+ result[i] = appendText(msg, `\n\n${nudgeText}`);
145
+ break;
146
+ }
147
+
148
+ return result;
149
+ }
@@ -0,0 +1,75 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { SessionState } from "../state/types.ts";
3
+ import { countMessageTokens } from "../utils/tokens.ts";
4
+
5
+ export interface MessagePriorityEntry {
6
+ index: number;
7
+ ref: string;
8
+ priority: number;
9
+ tokens: number;
10
+ }
11
+
12
+ export type PriorityMap = Map<number, MessagePriorityEntry>;
13
+
14
+ /**
15
+ * Build a priority map for message-mode compression.
16
+ * Priority 1 = highest (compress first), 5 = lowest (keep).
17
+ *
18
+ * Ranking factors:
19
+ * - Position: earlier messages get higher priority (compress first)
20
+ * - Token count: larger messages get higher priority (compress first)
21
+ * - Role: tool results are resolved content, slightly prioritized for compression
22
+ */
23
+ export function buildPriorityMap(
24
+ state: SessionState,
25
+ messages: AgentMessage[],
26
+ ): PriorityMap {
27
+ if (messages.length === 0) return new Map();
28
+
29
+ const entries: Array<{ index: number; score: number; tokens: number }> = [];
30
+
31
+ for (let i = 0; i < messages.length; i++) {
32
+ const msg = messages[i];
33
+ const ref = state.messageIds.byIndex.get(i);
34
+ if (!ref) continue;
35
+
36
+ // Skip messages already covered by active blocks
37
+ const pruneEntry = state.prune.messages.byMessageIndex.get(i);
38
+ if (pruneEntry && pruneEntry.activeBlockIds.length > 0) continue;
39
+
40
+ const tokens = countMessageTokens(msg);
41
+
42
+ // Score: higher = compress first
43
+ // Position weight: earlier messages score higher
44
+ const positionScore = (messages.length - i) / messages.length;
45
+ // Token weight: larger messages score higher
46
+ const tokenScore = Math.min(tokens / 500, 1);
47
+ // Role weight
48
+ const roleWeight = msg.role === "toolResult" ? 0.2 : 0;
49
+
50
+ const score = positionScore * 0.6 + tokenScore * 0.3 + roleWeight;
51
+ entries.push({ index: i, score, tokens });
52
+ }
53
+
54
+ // Sort by score descending (highest score = highest priority)
55
+ entries.sort((a, b) => b.score - a.score);
56
+
57
+ // Assign priorities 1-5 based on quintiles
58
+ const map: PriorityMap = new Map();
59
+ const quintileSize = Math.max(1, Math.ceil(entries.length / 5));
60
+
61
+ for (let rank = 0; rank < entries.length; rank++) {
62
+ const entry = entries[rank];
63
+ const priority = Math.min(5, Math.floor(rank / quintileSize) + 1);
64
+ const ref = state.messageIds.byIndex.get(entry.index)!;
65
+
66
+ map.set(entry.index, {
67
+ index: entry.index,
68
+ ref,
69
+ priority,
70
+ tokens: entry.tokens,
71
+ });
72
+ }
73
+
74
+ return map;
75
+ }
@@ -0,0 +1,103 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { SessionState } from "../state/types.ts";
3
+
4
+ /**
5
+ * Filter out compressed message ranges and inject summaries.
6
+ * Messages covered by active blocks are removed and replaced with
7
+ * a synthetic user message containing the summary at the anchor position.
8
+ */
9
+ export function filterCompressedRanges(
10
+ state: SessionState,
11
+ messages: AgentMessage[],
12
+ ): AgentMessage[] {
13
+ if (state.prune.messages.activeBlockIds.size === 0) return messages;
14
+
15
+ const result: AgentMessage[] = [];
16
+
17
+ for (let i = 0; i < messages.length; i++) {
18
+ // Check if there's a summary to inject at this anchor point
19
+ const blockId = state.prune.messages.activeByAnchorIndex.get(i);
20
+ if (blockId !== undefined) {
21
+ const block = state.prune.messages.blocksById.get(blockId);
22
+ if (block?.active && block.summary) {
23
+ result.push({
24
+ role: "user",
25
+ content: [{ type: "text", text: block.summary }],
26
+ timestamp: Date.now(),
27
+ } as AgentMessage);
28
+ }
29
+ }
30
+
31
+ // Skip messages that are covered by active blocks
32
+ const entry = state.prune.messages.byMessageIndex.get(i);
33
+ if (entry && entry.activeBlockIds.length > 0) {
34
+ continue;
35
+ }
36
+
37
+ result.push(messages[i]);
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ const PRUNED_OUTPUT_TEXT =
44
+ "[Output removed to save context - information superseded or no longer needed]";
45
+ const PRUNED_ERROR_INPUT_TEXT = "[input removed due to failed tool call]";
46
+
47
+ /**
48
+ * Replace outputs of pruned tool results with placeholder text.
49
+ * Returns a new array (does not mutate input).
50
+ */
51
+ export function pruneToolOutputs(
52
+ state: SessionState,
53
+ messages: AgentMessage[],
54
+ ): AgentMessage[] {
55
+ if (state.prune.tools.size === 0) return messages;
56
+
57
+ return messages.map((msg) => {
58
+ if (msg.role !== "toolResult") return msg;
59
+ if (!state.prune.tools.has(msg.toolCallId)) return msg;
60
+ if (msg.isError) return msg;
61
+
62
+ return {
63
+ ...msg,
64
+ content: [{ type: "text" as const, text: PRUNED_OUTPUT_TEXT }],
65
+ };
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Replace content of pruned error tool results with placeholder text.
71
+ * Returns a new array (does not mutate input).
72
+ */
73
+ export function pruneToolErrors(
74
+ state: SessionState,
75
+ messages: AgentMessage[],
76
+ ): AgentMessage[] {
77
+ if (state.prune.tools.size === 0) return messages;
78
+
79
+ return messages.map((msg) => {
80
+ if (msg.role !== "toolResult") return msg;
81
+ if (!state.prune.tools.has(msg.toolCallId)) return msg;
82
+ if (!msg.isError) return msg;
83
+
84
+ return {
85
+ ...msg,
86
+ content: [{ type: "text" as const, text: PRUNED_ERROR_INPUT_TEXT }],
87
+ };
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Apply all pruning passes to a message array.
93
+ * Returns a new array.
94
+ */
95
+ export function applyPruning(
96
+ state: SessionState,
97
+ messages: AgentMessage[],
98
+ ): AgentMessage[] {
99
+ let result = filterCompressedRanges(state, messages);
100
+ result = pruneToolOutputs(state, result);
101
+ result = pruneToolErrors(state, result);
102
+ return result;
103
+ }
@@ -0,0 +1,23 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { mapText } from "../utils/message-content.ts";
3
+
4
+ const DCP_PAIRED_TAG_REGEX = /<dcp[^>]*>[\s\S]*?<\/dcp[^>]*>/gi;
5
+ const DCP_UNPAIRED_TAG_REGEX = /<\/?dcp[^>]*>/gi;
6
+
7
+ /**
8
+ * Strip hallucinated DCP tags from a string.
9
+ */
10
+ export function stripHallucinationsFromString(text: string): string {
11
+ return text.replace(DCP_PAIRED_TAG_REGEX, "").replace(DCP_UNPAIRED_TAG_REGEX, "");
12
+ }
13
+
14
+ /**
15
+ * Strip hallucinated DCP tags from assistant messages.
16
+ * Returns a new array. Messages without changes are returned by reference.
17
+ */
18
+ export function stripHallucinations(messages: AgentMessage[]): AgentMessage[] {
19
+ return messages.map((msg) => {
20
+ if (msg.role !== "assistant") return msg;
21
+ return mapText(msg, stripHallucinationsFromString);
22
+ });
23
+ }
@@ -0,0 +1,55 @@
1
+ import type { SessionState } from "../state/types.ts";
2
+
3
+ /**
4
+ * Reconcile compression block state with current message count.
5
+ * Blocks whose compressMessageIndex exceeds the message array length
6
+ * are deactivated (the compress tool call was compacted away).
7
+ */
8
+ export function syncCompressionBlocks(
9
+ state: SessionState,
10
+ messageCount: number,
11
+ ): void {
12
+ const messagesState = state.prune.messages;
13
+ if (messagesState.blocksById.size === 0) return;
14
+
15
+ const now = Date.now();
16
+
17
+ // Sort blocks by creation order for deterministic processing
18
+ const blocks = Array.from(messagesState.blocksById.values()).sort(
19
+ (a, b) => a.createdAt - b.createdAt || a.blockId - b.blockId,
20
+ );
21
+
22
+ messagesState.activeBlockIds.clear();
23
+ messagesState.activeByAnchorIndex.clear();
24
+
25
+ for (const block of blocks) {
26
+ // If the compress tool call message no longer exists, deactivate
27
+ if (block.compressMessageIndex >= messageCount) {
28
+ block.active = false;
29
+ block.deactivatedAt = now;
30
+ continue;
31
+ }
32
+
33
+ if (block.deactivatedByUser) {
34
+ block.active = false;
35
+ if (block.deactivatedAt === undefined) {
36
+ block.deactivatedAt = now;
37
+ }
38
+ continue;
39
+ }
40
+
41
+ // Reactivate if the compress message still exists
42
+ block.active = true;
43
+ block.deactivatedAt = undefined;
44
+ block.deactivatedByBlockId = undefined;
45
+ messagesState.activeBlockIds.add(block.blockId);
46
+ messagesState.activeByAnchorIndex.set(block.anchorIndex, block.blockId);
47
+ }
48
+
49
+ // Update per-message entries
50
+ for (const entry of messagesState.byMessageIndex.values()) {
51
+ entry.activeBlockIds = entry.blockIds.filter((id) =>
52
+ messagesState.activeBlockIds.has(id),
53
+ );
54
+ }
55
+ }
@@ -0,0 +1,64 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { SessionState, ContextUsage } from "./state/types.ts";
3
+ import type { DcpConfig } from "./config.ts";
4
+ import { syncCompressionBlocks } from "./messages/sync.ts";
5
+ import { stripHallucinations } from "./messages/strip.ts";
6
+ import { syncToolCache, buildToolIdList } from "./state/tool-cache.ts";
7
+ import { runStrategies, type StrategyResult } from "./strategies/runner.ts";
8
+ import {
9
+ assignMessageRefs,
10
+ injectCompressNudges,
11
+ injectMessageIds,
12
+ } from "./messages/inject.ts";
13
+ import { buildPriorityMap, type PriorityMap } from "./messages/priority.ts";
14
+ import { applyPruning } from "./messages/prune.ts";
15
+
16
+ export interface PipelineResult {
17
+ messages: AgentMessage[];
18
+ strategyResult: StrategyResult;
19
+ }
20
+
21
+ /**
22
+ * Run the full DCP context processing pipeline.
23
+ * Pure function of (state, config, messages, usage) → transformed messages.
24
+ * State is mutated (tool cache, pruning marks, stats) as a side effect.
25
+ */
26
+ export function runPipeline(
27
+ state: SessionState,
28
+ config: DcpConfig,
29
+ messages: AgentMessage[],
30
+ contextUsage: ContextUsage | undefined,
31
+ ): PipelineResult {
32
+ // Step 0.5: Sync compression blocks (handle stale anchors)
33
+ syncCompressionBlocks(state, messages.length);
34
+
35
+ // Step 1: Strip hallucinated DCP tags from assistant messages
36
+ let result = stripHallucinations(messages);
37
+
38
+ // Step 2: Sync tool parameter cache and rebuild ordered tool ID list
39
+ syncToolCache(state, result);
40
+ buildToolIdList(state, result);
41
+
42
+ // Step 3: Run strategies (deduplication + purge errors)
43
+ const strategyResult = runStrategies(state, config);
44
+
45
+ // Step 4: Assign message refs (stable raw indices)
46
+ assignMessageRefs(state, result);
47
+
48
+ // Step 4.5: Build priority map for message-mode compression
49
+ let priorityMap: PriorityMap | undefined;
50
+ if (config.compress.mode === "message") {
51
+ priorityMap = buildPriorityMap(state, result);
52
+ }
53
+
54
+ // Step 5: Inject message IDs (with priority attrs if message mode)
55
+ result = injectMessageIds(state, result, priorityMap);
56
+
57
+ // Step 6: Apply pruning (compressed ranges removed, tool outputs pruned)
58
+ result = applyPruning(state, result);
59
+
60
+ // Step 7: Inject nudges based on context usage
61
+ result = injectCompressNudges(state, config, result, contextUsage);
62
+
63
+ return { messages: result, strategyResult };
64
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Message-mode compress tool description.
3
+ * Used when config.compress.mode === "message".
4
+ */
5
+ export const COMPRESS_MESSAGE_PROMPT = `Compress specific messages identified by their priority tags.
6
+
7
+ Messages are tagged with <dcp-message-id priority="N"> where N is 1-5:
8
+ - Priority 1-2: Highest compression value (old, large, resolved content)
9
+ - Priority 3: Moderate compression value
10
+ - Priority 4-5: Low compression value (recent, small, active content)
11
+
12
+ TARGET SELECTION
13
+ Focus on priority 1-2 messages first. These are the best candidates for compression.
14
+ Only compress priority 3+ messages when context pressure is severe.
15
+
16
+ SUMMARY REQUIREMENTS
17
+ Each summary must be self-contained and capture all essential information from the target message.
18
+ Preserve exact error messages, file paths, function names, and user instructions.
19
+ `;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Nudge prompts injected into messages when context usage exceeds thresholds.
3
+ * All wrapped in <dcp-system-reminder> tags — the model is trained to strip these.
4
+ */
5
+
6
+ /** Injected when context exceeds maxContextPercent. Urgent. */
7
+ export const CONTEXT_LIMIT_NUDGE = `<dcp-system-reminder>
8
+ CRITICAL WARNING: MAX CONTEXT LIMIT REACHED
9
+
10
+ You are at or beyond the configured max context threshold. This is an emergency context-recovery moment.
11
+
12
+ You MUST use the \`compress\` tool now. Do not continue normal exploration until compression is handled.
13
+
14
+ If you are in the middle of a critical atomic operation, finish that atomic step first, then compress immediately.
15
+
16
+ SELECTION PROCESS
17
+ Start from older, resolved history and capture as much stale context as safely possible in one pass.
18
+ Avoid the newest active working messages unless it is clearly closed.
19
+
20
+ SUMMARY REQUIREMENTS
21
+ Your summary MUST cover all essential details from the selected messages so work can continue.
22
+ If the compressed range includes user messages, preserve user intent exactly. Prefer direct quotes for short user messages to avoid semantic drift.
23
+ </dcp-system-reminder>
24
+ `;
25
+
26
+ /** Injected on user turns when context is between minContextPercent and maxContextPercent. Moderate. */
27
+ export const TURN_NUDGE = `<dcp-system-reminder>
28
+ Evaluate the conversation for compressible ranges.
29
+
30
+ If any messages are cleanly closed and unlikely to be needed again, use the compress tool on them.
31
+ If direction has shifted, compress earlier ranges that are now less relevant.
32
+
33
+ The goal is to filter noise and distill key information so context accumulation stays under control.
34
+ Keep active context uncompressed.
35
+ </dcp-system-reminder>
36
+ `;
37
+
38
+ /** Injected after many assistant iterations without a user message. Moderate. */
39
+ export const ITERATION_NUDGE = `<dcp-system-reminder>
40
+ You've been iterating for a while after the last user message.
41
+
42
+ If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation), use the compress tool on it now.
43
+ </dcp-system-reminder>
44
+ `;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * DCP system prompt appended to Pi's system prompt via before_agent_start.
3
+ * Teaches the model about context management and the compress tool.
4
+ */
5
+ export const DCP_SYSTEM_PROMPT = `
6
+ You operate in a context-constrained environment. Manage context continuously to avoid buildup and preserve retrieval quality. Efficient context management is paramount for your agentic performance.
7
+
8
+ The ONLY tool you have for context management is \`compress\`. It replaces older conversation content with technical summaries you produce.
9
+
10
+ \`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
11
+
12
+ THE PHILOSOPHY OF COMPRESS
13
+ \`compress\` transforms conversation content into dense, high-fidelity summaries. This is not cleanup - it is crystallization. Your summary becomes the authoritative record of what transpired.
14
+
15
+ Think of compression as phase transitions: raw exploration becomes refined understanding. The original context served its purpose; your summary now carries that understanding forward.
16
+
17
+ COMPRESS WHEN
18
+
19
+ A section is genuinely closed and the raw conversation has served its purpose:
20
+
21
+ - Research concluded and findings are clear
22
+ - Implementation finished and verified
23
+ - Exploration exhausted and patterns understood
24
+ - Dead-end noise can be discarded without waiting for a whole chapter to close
25
+
26
+ DO NOT COMPRESS IF
27
+
28
+ - Raw context is still relevant and needed for edits or precise references
29
+ - The target content is still actively in progress
30
+ - You may need exact code, error messages, or file contents in the immediate next steps
31
+
32
+ Before compressing, ask: _"Is this section closed enough to become summary-only right now?"_
33
+
34
+ Evaluate conversation signal-to-noise REGULARLY. Use \`compress\` deliberately with quality-first summaries. Prioritize stale content intelligently to maintain a high-signal context window that supports your agency.
35
+
36
+ It is of your responsibility to keep a sharp, high-quality context window for optimal performance.
37
+ `;
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { SessionState, SessionStats } from "./types.ts";
4
+
5
+ /**
6
+ * Serializable subset of session state for persistence.
7
+ */
8
+ interface SerializedState {
9
+ sessionId: string | null;
10
+ currentTurn: number;
11
+ stats: SessionStats;
12
+ lastCompaction: number;
13
+ }
14
+
15
+ /**
16
+ * Save session state to {sessionDir}/dcp/state.json.
17
+ * No-op if state.sessionId is null.
18
+ */
19
+ export function saveSessionState(state: SessionState, sessionDir: string): void {
20
+ if (!state.sessionId) return;
21
+
22
+ const dcpDir = path.join(sessionDir, "dcp");
23
+ fs.mkdirSync(dcpDir, { recursive: true });
24
+
25
+ const serialized: SerializedState = {
26
+ sessionId: state.sessionId,
27
+ currentTurn: state.currentTurn,
28
+ stats: { ...state.stats },
29
+ lastCompaction: state.lastCompaction,
30
+ };
31
+
32
+ fs.writeFileSync(
33
+ path.join(dcpDir, "state.json"),
34
+ JSON.stringify(serialized, null, 2),
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Load session state from {sessionDir}/dcp/state.json.
40
+ * Returns undefined if the file doesn't exist or is corrupt.
41
+ */
42
+ export function loadSessionState(
43
+ sessionDir: string,
44
+ ): Pick<SessionState, "currentTurn" | "stats" | "lastCompaction"> | undefined {
45
+ const filePath = path.join(sessionDir, "dcp", "state.json");
46
+
47
+ try {
48
+ if (!fs.existsSync(filePath)) return undefined;
49
+ const content = fs.readFileSync(filePath, "utf-8");
50
+ const parsed = JSON.parse(content) as SerializedState;
51
+
52
+ return {
53
+ currentTurn: parsed.currentTurn ?? 0,
54
+ stats: {
55
+ pruneTokenCounter: parsed.stats?.pruneTokenCounter ?? 0,
56
+ totalPruneTokens: parsed.stats?.totalPruneTokens ?? 0,
57
+ toolsPruned: parsed.stats?.toolsPruned ?? 0,
58
+ messagesCompressed: parsed.stats?.messagesCompressed ?? 0,
59
+ },
60
+ lastCompaction: parsed.lastCompaction ?? 0,
61
+ };
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Load aggregate stats from all saved sessions under a parent directory.
69
+ * Expects structure: {parentDir}/{sessionName}/dcp/state.json
70
+ * Used by dcp:lifetime command.
71
+ */
72
+ export function loadAllSessionStats(parentDir: string): {
73
+ totalTokensSaved: number;
74
+ totalToolsPruned: number;
75
+ totalMessagesCompressed: number;
76
+ sessionCount: number;
77
+ } {
78
+ const result = {
79
+ totalTokensSaved: 0,
80
+ totalToolsPruned: 0,
81
+ totalMessagesCompressed: 0,
82
+ sessionCount: 0,
83
+ };
84
+
85
+ try {
86
+ if (!fs.existsSync(parentDir)) return result;
87
+ const entries = fs.readdirSync(parentDir, { withFileTypes: true });
88
+
89
+ for (const entry of entries) {
90
+ if (!entry.isDirectory()) continue;
91
+ const stateFile = path.join(parentDir, entry.name, "dcp", "state.json");
92
+ try {
93
+ if (!fs.existsSync(stateFile)) continue;
94
+ const content = fs.readFileSync(stateFile, "utf-8");
95
+ const parsed = JSON.parse(content) as SerializedState;
96
+ if (parsed.stats) {
97
+ result.totalTokensSaved += parsed.stats.totalPruneTokens ?? 0;
98
+ result.totalToolsPruned += parsed.stats.toolsPruned ?? 0;
99
+ result.totalMessagesCompressed += parsed.stats.messagesCompressed ?? 0;
100
+ result.sessionCount++;
101
+ }
102
+ } catch {
103
+ // Skip corrupt files
104
+ }
105
+ }
106
+ } catch {
107
+ // Dir not accessible
108
+ }
109
+
110
+ return result;
111
+ }