@mrclrchtr/supi-cache 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.
Files changed (33) hide show
  1. package/README.md +119 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +44 -0
  19. package/src/config.ts +37 -0
  20. package/src/fingerprint.ts +187 -0
  21. package/src/forensics/extract.ts +129 -0
  22. package/src/forensics/forensics.ts +214 -0
  23. package/src/forensics/queries.ts +74 -0
  24. package/src/forensics/redact.ts +61 -0
  25. package/src/forensics/types.ts +59 -0
  26. package/src/hash.ts +16 -0
  27. package/src/index.ts +1 -0
  28. package/src/monitor/monitor.ts +320 -0
  29. package/src/monitor/state.ts +308 -0
  30. package/src/monitor/status.ts +33 -0
  31. package/src/report/forensics.ts +165 -0
  32. package/src/report/history.ts +197 -0
  33. package/src/settings-registration.ts +80 -0
@@ -0,0 +1,226 @@
1
+ // Generic settings overlay for SuPi extensions.
2
+ //
3
+ // Uses pi-tui's SettingsList with scope toggle (Tab), extension grouping,
4
+ // and search. Each extension declares its settings via registerSettings().
5
+
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ Container,
10
+ Input,
11
+ Key,
12
+ matchesKey,
13
+ type SettingItem,
14
+ SettingsList,
15
+ Text,
16
+ } from "@earendil-works/pi-tui";
17
+ import {
18
+ getRegisteredSettings,
19
+ type SettingsScope,
20
+ type SettingsSection,
21
+ } from "./settings-registry.ts";
22
+
23
+ // ── Input submenu component ──────────────────────────────────
24
+
25
+ /**
26
+ * Creates a pi-tui Input-backed submenu component with enter-to-confirm
27
+ * and escape-to-cancel handling.
28
+ *
29
+ * @param currentValue - Initial value for the text input.
30
+ * @param label - Label text displayed above the input.
31
+ * @param done - Callback invoked with the confirmed value, or undefined on cancel.
32
+ */
33
+ export function createInputSubmenu(
34
+ currentValue: string,
35
+ label: string,
36
+ done: (selectedValue?: string) => void,
37
+ ): {
38
+ render: (width: number) => string[];
39
+ invalidate: () => void;
40
+ handleInput: (data: string) => boolean;
41
+ } {
42
+ const input = new Input();
43
+ input.setValue(currentValue);
44
+
45
+ return {
46
+ render: (_width: number) => {
47
+ const lines = [` ${label}`];
48
+ lines.push(...input.render(_width));
49
+ lines.push(" enter confirm • esc cancel");
50
+ return lines;
51
+ },
52
+ invalidate: () => {
53
+ input.invalidate();
54
+ },
55
+ handleInput: (data: string) => {
56
+ if (matchesKey(data, Key.escape)) {
57
+ done();
58
+ return true;
59
+ }
60
+ if (matchesKey(data, Key.enter)) {
61
+ done(input.getValue());
62
+ return true;
63
+ }
64
+ input.handleInput(data);
65
+ return true;
66
+ },
67
+ };
68
+ }
69
+
70
+ // ── Types ────────────────────────────────────────────────────
71
+
72
+ interface OverlayState {
73
+ scope: SettingsScope;
74
+ cwd: string;
75
+ }
76
+
77
+ // ── Pure helpers ─────────────────────────────────────────────
78
+
79
+ function getScopeLabel(scope: SettingsScope): string {
80
+ return scope === "project" ? "Project" : "Global";
81
+ }
82
+
83
+ function buildFlatItems(
84
+ sections: SettingsSection[],
85
+ scope: SettingsScope,
86
+ cwd: string,
87
+ ): SettingItem[] {
88
+ const items: SettingItem[] = [];
89
+ for (const section of sections) {
90
+ const sectionItems = section.loadValues(scope, cwd);
91
+ for (const item of sectionItems) {
92
+ items.push({
93
+ ...item,
94
+ id: `${section.id}.${item.id}`,
95
+ label: `${section.label}: ${item.label}`,
96
+ });
97
+ }
98
+ }
99
+ return items;
100
+ }
101
+
102
+ function findSectionAndId(
103
+ sections: SettingsSection[],
104
+ flatId: string,
105
+ ): { section: SettingsSection; itemId: string } | null {
106
+ const dotIndex = flatId.indexOf(".");
107
+ if (dotIndex === -1) return null;
108
+ const sectionId = flatId.slice(0, dotIndex);
109
+ const itemId = flatId.slice(dotIndex + 1);
110
+ const section = sections.find((s) => s.id === sectionId);
111
+ if (!section) return null;
112
+ return { section, itemId };
113
+ }
114
+
115
+ // ── Component ────────────────────────────────────────────────
116
+
117
+ interface SettingsOverlayDeps {
118
+ state: OverlayState;
119
+ container: Container;
120
+ settingsList: SettingsList | null;
121
+ tui: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[0];
122
+ theme: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[1];
123
+ done: () => void;
124
+ }
125
+
126
+ function createSettingsList(deps: SettingsOverlayDeps): SettingsList {
127
+ const sections = getRegisteredSettings();
128
+ const items = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
129
+ const onChange = (flatId: string, newValue: string) => {
130
+ const found = findSectionAndId(sections, flatId);
131
+ if (found) {
132
+ found.section.persistChange(deps.state.scope, deps.state.cwd, found.itemId, newValue);
133
+ }
134
+ // Re-read all values to reflect persisted changes, but keep the list
135
+ // instance (and its selectedIndex) intact.
136
+ const updatedItems = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
137
+ for (const updated of updatedItems) {
138
+ const existing = items.find((i) => i.id === updated.id);
139
+ if (existing && existing.currentValue !== updated.currentValue) {
140
+ settingsList.updateValue(updated.id, updated.currentValue);
141
+ }
142
+ }
143
+ deps.tui.requestRender();
144
+ };
145
+ const settingsList = new SettingsList(
146
+ items,
147
+ Math.min(items.length + 4, 20),
148
+ getSettingsListTheme(),
149
+ onChange,
150
+ () => deps.done(),
151
+ { enableSearch: true },
152
+ );
153
+ return settingsList;
154
+ }
155
+
156
+ function rebuildSettingsList(deps: SettingsOverlayDeps): SettingsList {
157
+ const settingsList = createSettingsList(deps);
158
+ deps.settingsList = settingsList;
159
+
160
+ deps.container.clear();
161
+ deps.container.addChild(createHeaderComponent(deps));
162
+ deps.container.addChild(settingsList);
163
+
164
+ return settingsList;
165
+ }
166
+
167
+ function createHeaderComponent(deps: SettingsOverlayDeps): Text {
168
+ const { theme, state } = deps;
169
+ const scopeLabel = getScopeLabel(state.scope);
170
+ const otherScope = state.scope === "project" ? "Global" : "Project";
171
+ const headerText = new Text(
172
+ `${theme.fg("accent", theme.bold("SuPi Settings"))} ${theme.fg("text", `Scope: ${scopeLabel}`)} ${theme.fg("dim", `(tab → ${otherScope})`)}`,
173
+ 0,
174
+ 0,
175
+ );
176
+ return headerText;
177
+ }
178
+
179
+ function handleScopeToggle(deps: SettingsOverlayDeps): void {
180
+ deps.state.scope = deps.state.scope === "project" ? "global" : "project";
181
+ rebuildSettingsList(deps);
182
+ deps.tui.requestRender();
183
+ }
184
+
185
+ // ── Entry point ──────────────────────────────────────────────
186
+
187
+ export function openSettingsOverlay(ctx: ExtensionContext): void {
188
+ const sections = getRegisteredSettings();
189
+ if (sections.length === 0) {
190
+ ctx.ui.notify("No settings registered by SuPi extensions", "info");
191
+ return;
192
+ }
193
+
194
+ void ctx.ui.custom<void>((tui, theme, _kb, done) => {
195
+ const state: OverlayState = { scope: "project", cwd: ctx.cwd };
196
+ const container = new Container();
197
+
198
+ const deps: SettingsOverlayDeps = {
199
+ state,
200
+ container,
201
+ settingsList: null,
202
+ tui,
203
+ theme,
204
+ done,
205
+ };
206
+
207
+ rebuildSettingsList(deps);
208
+
209
+ const component = {
210
+ render: (width: number) => container.render(width),
211
+ invalidate: () => container.invalidate(),
212
+ handleInput: (data: string) => {
213
+ if (matchesKey(data, Key.tab)) {
214
+ handleScopeToggle(deps);
215
+ return true;
216
+ }
217
+ // Delegate input to the settings list (always set after rebuildSettingsList)
218
+ deps.settingsList?.handleInput?.(data);
219
+ deps.tui.requestRender();
220
+ return true;
221
+ },
222
+ };
223
+
224
+ return component;
225
+ });
226
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared terminal title formatting and signaling utilities.
3
+ *
4
+ * Centralized place for pi title convention (π prefix), completion (✓)
5
+ * and waiting (●) indicators, and the audible terminal bell.
6
+ */
7
+ import path from "node:path";
8
+
9
+ /** Unicode checkmark shown when the agent finishes a turn. */
10
+ export const DONE_SYMBOL = "\u2713";
11
+ /** Unicode dot shown when waiting for user input. */
12
+ export const WAITING_SYMBOL = "\u25CF";
13
+
14
+ /** Minimal UI surface needed for title operations. */
15
+ export interface TitleTarget {
16
+ ui: {
17
+ setTitle?(title: string): void;
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Format pi's canonical terminal title from session name and cwd.
23
+ * Falls back gracefully when either is missing.
24
+ *
25
+ * @example
26
+ * formatTitle("my-session", "/home/projects/foo") // "π - my-session - foo"
27
+ * formatTitle(undefined, "/home/projects/foo") // "π - foo"
28
+ * formatTitle("my-session") // "π - my-session"
29
+ * formatTitle() // "π"
30
+ */
31
+ export function formatTitle(sessionName?: string, cwd?: string): string {
32
+ const base = cwd ? path.basename(cwd) : undefined;
33
+ if (sessionName && base) return `π - ${sessionName} - ${base}`;
34
+ if (sessionName) return `π - ${sessionName}`;
35
+ if (base) return `π - ${base}`;
36
+ return "π";
37
+ }
38
+
39
+ /** Sound the audible terminal bell (ASCII BEL). */
40
+ export function signalBell(): void {
41
+ process.stdout.write("\x07");
42
+ }
43
+
44
+ /**
45
+ * Set the terminal title to indicate the agent is waiting for user input.
46
+ * Prefixes with ● and sounds the terminal bell.
47
+ */
48
+ export function signalWaiting(ctx: TitleTarget, title: string): void {
49
+ ctx.ui.setTitle?.(`${WAITING_SYMBOL} ${title}`);
50
+ signalBell();
51
+ }
52
+
53
+ /**
54
+ * Set the terminal title to indicate the agent turn has completed.
55
+ * Prefixes with ✓ and sounds the terminal bell.
56
+ */
57
+ export function signalDone(ctx: TitleTarget, title: string): void {
58
+ ctx.ui.setTitle?.(`${DONE_SYMBOL} ${title}`);
59
+ signalBell();
60
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mrclrchtr/supi-cache",
3
+ "version": "0.1.0",
4
+ "description": "SuPi Cache — prompt cache health monitoring and cross-session forensics",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/mrclrchtr/supi.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi",
16
+ "pi-coding-agent"
17
+ ],
18
+ "files": [
19
+ "src/**/*.ts",
20
+ "README.md"
21
+ ],
22
+ "dependencies": {
23
+ "@mrclrchtr/supi-core": "workspace:*"
24
+ },
25
+ "bundledDependencies": [
26
+ "@mrclrchtr/supi-core"
27
+ ],
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-ai": "*",
30
+ "@earendil-works/pi-coding-agent": "*",
31
+ "@earendil-works/pi-tui": "*",
32
+ "typebox": "*"
33
+ },
34
+ "devDependencies": {
35
+ "vitest": "^4.1.4",
36
+ "@mrclrchtr/supi-test-utils": "workspace:*"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./src/index.ts"
41
+ ]
42
+ },
43
+ "main": "src/index.ts"
44
+ }
package/src/config.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Configuration for supi-cache.
2
+ //
3
+ // Config shape (in supi shared config, "supi-cache" section):
4
+ // {
5
+ // "enabled": true, // enable/disable cache monitoring
6
+ // "notifications": true, // show regression warning notifications
7
+ // "regressionThreshold": 25, // percentage-point drop that triggers a warning
8
+ // "idleThresholdMinutes": 5 // gap in minutes to classify as idle-time regression
9
+ // }
10
+
11
+ import { loadSupiConfig } from "@mrclrchtr/supi-core";
12
+
13
+ export interface CacheMonitorConfig {
14
+ /** Enable/disable cache monitoring. Default: true */
15
+ enabled: boolean;
16
+ /** Show regression warning notifications. Default: true */
17
+ notifications: boolean;
18
+ /** Percentage-point drop that triggers a regression warning. Default: 25 */
19
+ regressionThreshold: number;
20
+ /** Gap in minutes between turns to classify as idle-time regression. Default: 5 */
21
+ idleThresholdMinutes: number;
22
+ }
23
+
24
+ export const CACHE_MONITOR_DEFAULTS: CacheMonitorConfig = {
25
+ enabled: true,
26
+ notifications: true,
27
+ regressionThreshold: 25,
28
+ idleThresholdMinutes: 5,
29
+ };
30
+
31
+ export function loadCacheMonitorConfig(cwd: string, homeDir?: string): CacheMonitorConfig {
32
+ // Read the new section first, then fall back to the old section for upgrades.
33
+ const merged = loadSupiConfig("supi-cache", cwd, CACHE_MONITOR_DEFAULTS, { homeDir });
34
+ const legacy = loadSupiConfig("cache-monitor", cwd, CACHE_MONITOR_DEFAULTS, { homeDir });
35
+ // Prefer new-section values when present; keep defaults as the base.
36
+ return { ...CACHE_MONITOR_DEFAULTS, ...legacy, ...merged };
37
+ }
@@ -0,0 +1,187 @@
1
+ // Prompt fingerprint computation and diffing for granular prompt change detection.
2
+ //
3
+ // Computes a structured fingerprint from `BuildSystemPromptOptions` on every
4
+ // `before_agent_start` so that later regression diagnostics can describe
5
+ // exactly which prompt component changed (context files, tools, skills, etc.).
6
+
7
+ import type { BuildSystemPromptOptions } from "@earendil-works/pi-coding-agent";
8
+ import { fastHash } from "./hash.ts";
9
+
10
+ /** Per-component fingerprint of the structured system prompt options. */
11
+ export interface PromptFingerprint {
12
+ /** Hash of `customPrompt` text, or 0 if absent. */
13
+ customPromptHash: number;
14
+ /** Hash of `appendSystemPrompt` text, or 0 if absent. */
15
+ appendSystemPromptHash: number;
16
+ /** Hash of joined `promptGuidelines` array, or 0 if absent. */
17
+ promptGuidelinesHash: number;
18
+ /** Hash of sorted `selectedTools` array joined by comma, or 0 if absent. */
19
+ selectedToolsHash: number;
20
+ /** Hash of joined `toolSnippets` values (sorted by key), or 0 if absent. */
21
+ toolSnippetsHash: number;
22
+ /** Per-context-file hashes, preserving insertion order. */
23
+ contextFiles: Array<{ path: string; hash: number }>;
24
+ /** Per-skill hashes, preserving insertion order. */
25
+ skills: Array<{ name: string; hash: number }>;
26
+ }
27
+
28
+ /**
29
+ * Compute a deterministic fingerprint from the structured system prompt options.
30
+ *
31
+ * Every component is independently fingerprinted so that `diffFingerprints` can
32
+ * identify exactly which components changed between consecutive turns.
33
+ *
34
+ * @param opts - The `systemPromptOptions` from a `before_agent_start` event.
35
+ * When `undefined` or empty, a zero-valued fingerprint is returned.
36
+ */
37
+ export function computePromptFingerprint(opts?: BuildSystemPromptOptions): PromptFingerprint {
38
+ if (!opts) {
39
+ return zeroFingerprint();
40
+ }
41
+
42
+ const customPromptHash = opts.customPrompt ? fastHash(opts.customPrompt) : 0;
43
+
44
+ const appendSystemPromptHash = opts.appendSystemPrompt ? fastHash(opts.appendSystemPrompt) : 0;
45
+
46
+ const promptGuidelinesHash = opts.promptGuidelines
47
+ ? fastHash(opts.promptGuidelines.join("\n"))
48
+ : 0;
49
+
50
+ const selectedToolsHash = opts.selectedTools
51
+ ? fastHash([...opts.selectedTools].sort().join(","))
52
+ : 0;
53
+
54
+ const toolSnippetsHash = opts.toolSnippets
55
+ ? fastHash(
56
+ Object.entries(opts.toolSnippets)
57
+ .sort(([a], [b]) => a.localeCompare(b))
58
+ .map(([, v]) => v)
59
+ .join("\n"),
60
+ )
61
+ : 0;
62
+
63
+ const contextFiles = (opts.contextFiles ?? []).map((cf) => ({
64
+ path: cf.path,
65
+ hash: fastHash(cf.content),
66
+ }));
67
+
68
+ const skills = (opts.skills ?? []).map((s) => ({
69
+ name: s.name,
70
+ hash: fastHash(`${s.description ?? ""}|${s.filePath ?? ""}`),
71
+ }));
72
+
73
+ return {
74
+ customPromptHash,
75
+ appendSystemPromptHash,
76
+ promptGuidelinesHash,
77
+ selectedToolsHash,
78
+ toolSnippetsHash,
79
+ contextFiles,
80
+ skills,
81
+ };
82
+ }
83
+
84
+ /** Compare two fingerprints and return a human-readable list of changes. */
85
+ export function diffFingerprints(prev: PromptFingerprint, curr: PromptFingerprint): string[] {
86
+ const changes: string[] = [];
87
+
88
+ // ── Context files: count added, modified, removed ─────────
89
+
90
+ const ctxDiff = diffArrays(prev.contextFiles, curr.contextFiles, (a) => a.path);
91
+ if (ctxDiff) {
92
+ changes.push(`contextFiles (${ctxDiff})`);
93
+ }
94
+
95
+ // ── Skills: count added, modified, removed ────────────────
96
+
97
+ const skillDiff = diffArrays(prev.skills, curr.skills, (a) => a.name);
98
+ if (skillDiff) {
99
+ changes.push(`skills (${skillDiff})`);
100
+ }
101
+
102
+ // ── Scalar component checks ───────────────────────────────
103
+
104
+ if (
105
+ prev.selectedToolsHash !== curr.selectedToolsHash ||
106
+ prev.toolSnippetsHash !== curr.toolSnippetsHash
107
+ ) {
108
+ changes.push("tools");
109
+ }
110
+
111
+ if (prev.promptGuidelinesHash !== curr.promptGuidelinesHash) {
112
+ changes.push("guidelines");
113
+ }
114
+
115
+ if (prev.customPromptHash !== curr.customPromptHash) {
116
+ changes.push("customPrompt");
117
+ }
118
+
119
+ if (prev.appendSystemPromptHash !== curr.appendSystemPromptHash) {
120
+ changes.push("appendText");
121
+ }
122
+
123
+ return changes;
124
+ }
125
+
126
+ // ── Internal helpers ──────────────────────────────────────────
127
+
128
+ /**
129
+ * Compare two arrays of `{ hash }` items by key, counting added, modified,
130
+ * and removed items. Uses key-based lookup (via `getKey`) so it correctly
131
+ * handles insertions and deletions anywhere in the array, not just at the end.
132
+ *
133
+ * Returns a human-readable summary like `+1, ~2, -1` when any differences
134
+ * exist, or `undefined` when the arrays are identical in content.
135
+ */
136
+ function diffArrays<T extends { hash: number }>(
137
+ prev: T[],
138
+ curr: T[],
139
+ getKey: (item: T) => string,
140
+ ): string | undefined {
141
+ const prevMap = new Map<string, number>();
142
+ const currMap = new Map<string, number>();
143
+
144
+ for (const item of prev) prevMap.set(getKey(item), item.hash);
145
+ for (const item of curr) currMap.set(getKey(item), item.hash);
146
+
147
+ let added = 0;
148
+ let modified = 0;
149
+ let removed = 0;
150
+
151
+ for (const [key, hash] of currMap) {
152
+ if (!prevMap.has(key)) {
153
+ added++;
154
+ } else if (prevMap.get(key) !== hash) {
155
+ modified++;
156
+ }
157
+ }
158
+
159
+ for (const key of prevMap.keys()) {
160
+ if (!currMap.has(key)) {
161
+ removed++;
162
+ }
163
+ }
164
+
165
+ if (added === 0 && modified === 0 && removed === 0) {
166
+ return undefined;
167
+ }
168
+
169
+ const parts: string[] = [];
170
+ if (added > 0) parts.push(`+${added}`);
171
+ if (modified > 0) parts.push(`~${modified}`);
172
+ if (removed > 0) parts.push(`-${removed}`);
173
+ return parts.join(", ");
174
+ }
175
+
176
+ /** Create a zero-valued fingerprint (all hashes 0, empty arrays). */
177
+ export function zeroFingerprint(): PromptFingerprint {
178
+ return {
179
+ customPromptHash: 0,
180
+ appendSystemPromptHash: 0,
181
+ promptGuidelinesHash: 0,
182
+ selectedToolsHash: 0,
183
+ toolSnippetsHash: 0,
184
+ contextFiles: [],
185
+ skills: [],
186
+ };
187
+ }
@@ -0,0 +1,129 @@
1
+ // Session extraction utilities — pull cache turns and tool windows from branches.
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import type { FileEntry, SessionEntry } from "@earendil-works/pi-coding-agent";
5
+ import { migrateSessionEntries, parseSessionEntries } from "@earendil-works/pi-coding-agent";
6
+ import type { TurnRecord } from "../monitor/state.ts";
7
+ import { computeToolCallShape } from "./redact.ts";
8
+ import type { ToolCallShape } from "./types.ts";
9
+
10
+ /**
11
+ * Read and parse a PI session file into FileEntry[].
12
+ *
13
+ * Calls migrateSessionEntries to handle legacy v1/v2 files.
14
+ */
15
+ export async function parseSessionFile(path: string): Promise<FileEntry[]> {
16
+ const content = await readFile(path, { encoding: "utf-8" });
17
+ const entries = parseSessionEntries(content);
18
+ migrateSessionEntries(entries);
19
+ return entries;
20
+ }
21
+
22
+ /**
23
+ * Filter a session branch for `supi-cache-turn` custom entries and return
24
+ * the parsed TurnRecord objects.
25
+ *
26
+ * Sessions without any cache turns return an empty array.
27
+ */
28
+ export function extractCacheTurnEntries(branch: SessionEntry[]): TurnRecord[] {
29
+ const turns: TurnRecord[] = [];
30
+ for (const entry of branch) {
31
+ if (entry.type === "custom" && entry.customType === "supi-cache-turn") {
32
+ const data = entry.data as TurnRecord | undefined;
33
+ if (data) {
34
+ turns.push(data);
35
+ }
36
+ }
37
+ }
38
+ return turns;
39
+ }
40
+
41
+ /**
42
+ * Extract tool-call shape fingerprints aligned by cache-turn timestamps.
43
+ *
44
+ * Returns a Map from **turnIndex** to the tool calls that occurred between
45
+ * `turn[i-lookback].timestamp` (inclusive) and `turn[i].timestamp` (exclusive).
46
+ *
47
+ * Uses turn timestamps rather than message count so that assistant messages
48
+ * without cache-turn entries (no usage reported) don't skew the window.
49
+ */
50
+ export function extractToolCallWindows(
51
+ branch: SessionEntry[],
52
+ lookback: number = 2,
53
+ ): Map<number, ToolCallShape[]> {
54
+ // First pass: collect all assistant messages with epoch timestamps and tools.
55
+ const assistantMessages: { timestampEpoch: number; tools: ToolCallShape[] }[] = [];
56
+ for (const entry of branch) {
57
+ if (entry.type === "message") {
58
+ const msg = entry.message as unknown as Record<string, unknown>;
59
+ if (msg.role === "assistant") {
60
+ assistantMessages.push({
61
+ timestampEpoch: new Date(entry.timestamp).getTime(),
62
+ tools: extractToolCallsFromMessage(msg),
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ // Collect cache turns with their turn-index and recorded timestamp.
69
+ const turns = extractCacheTurnEntries(branch);
70
+
71
+ // For each turn, find assistant messages in the preceding-turns window.
72
+ const result = new Map<number, ToolCallShape[]>();
73
+ for (let i = 0; i < turns.length; i++) {
74
+ const turn = turns[i];
75
+ const startIdx = Math.max(0, i - lookback);
76
+ const windowStart = turns[startIdx].timestamp;
77
+ const windowEnd = turn.timestamp;
78
+
79
+ const prevTools: ToolCallShape[] = [];
80
+ for (const msg of assistantMessages) {
81
+ if (msg.timestampEpoch >= windowStart && msg.timestampEpoch < windowEnd) {
82
+ prevTools.push(...msg.tools);
83
+ }
84
+ }
85
+ result.set(turn.turnIndex, prevTools);
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ function extractToolCallsFromMessage(msg: Record<string, unknown>): ToolCallShape[] {
92
+ const content = msg.content;
93
+ if (!Array.isArray(content)) return [];
94
+
95
+ const shapes: ToolCallShape[] = [];
96
+ for (const block of content) {
97
+ if (
98
+ block &&
99
+ typeof block === "object" &&
100
+ (block as Record<string, unknown>).type === "toolCall" &&
101
+ "name" in block
102
+ ) {
103
+ const toolName = String((block as Record<string, unknown>).name);
104
+ const args = (block as Record<string, unknown>).arguments as
105
+ | Record<string, unknown>
106
+ | undefined;
107
+ if (args && typeof args === "object") {
108
+ shapes.push(computeToolCallShape(toolName, args));
109
+ }
110
+ }
111
+ }
112
+ return shapes;
113
+ }
114
+
115
+ /**
116
+ * Find the most recent comparable turn (one with a defined hitRate)
117
+ * before the given index.
118
+ */
119
+ export function findPreviousComparableTurn(
120
+ turns: TurnRecord[],
121
+ index: number,
122
+ ): TurnRecord | undefined {
123
+ for (let i = index - 1; i >= 0; i--) {
124
+ if (turns[i].hitRate !== undefined) {
125
+ return turns[i];
126
+ }
127
+ }
128
+ return undefined;
129
+ }