@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,214 @@
1
+ // Forensics engine — scan pipeline for cross-session cache investigation.
2
+
3
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { getActiveBranchEntries } from "@mrclrchtr/supi-core";
5
+ import { resolveTurnCause } from "../monitor/state.ts";
6
+ import {
7
+ extractCacheTurnEntries,
8
+ extractToolCallWindows,
9
+ findPreviousComparableTurn,
10
+ parseSessionFile,
11
+ } from "./extract.ts";
12
+ import { breakdownCauses, correlateTools, detectIdleRegressions, findHotspots } from "./queries.ts";
13
+ import type { CauseBreakdown, ForensicsFinding, ForensicsOptions } from "./types.ts";
14
+
15
+ export interface ForensicsResult {
16
+ pattern: ForensicsOptions["pattern"];
17
+ /** Present for hotspots, correlate, and idle patterns. */
18
+ findings?: ForensicsFinding[];
19
+ /** Present for breakdown pattern. */
20
+ breakdown?: CauseBreakdown;
21
+ sessionsScanned: number;
22
+ turnsAnalyzed: number;
23
+ }
24
+
25
+ /**
26
+ * Run a forensics query across historical sessions.
27
+ *
28
+ * Pipeline:
29
+ * 1. List all sessions via SessionManager.listAll()
30
+ * 2. Filter by date range and maxSessions
31
+ * 3. Parse each session file, resolve active branch
32
+ * 4. Extract cache turns and tool windows
33
+ * 5. Build findings (compute drops, attach tool context)
34
+ * 6. Run the requested query pattern
35
+ */
36
+ export async function runForensics(options: ForensicsOptions): Promise<ForensicsResult> {
37
+ const sinceMs = parseDuration(options.since);
38
+ const cutoff = Date.now() - sinceMs;
39
+ const maxSessions = options.maxSessions ?? 100;
40
+ const idleThreshold = options.idleThresholdMinutes ?? 5;
41
+ const regressionThreshold = options.regressionThreshold ?? 25;
42
+ const lookback = options.lookback ?? 2;
43
+ const minDrop = options.minDrop ?? 0;
44
+
45
+ const allSessions = await SessionManager.listAll();
46
+ const recentSessions = allSessions
47
+ .filter((s) => s.modified.getTime() >= cutoff)
48
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime())
49
+ .slice(0, maxSessions);
50
+
51
+ let sessionsScanned = 0;
52
+ let turnsAnalyzed = 0;
53
+ const allFindings: ForensicsFinding[] = [];
54
+
55
+ for (const session of recentSessions) {
56
+ try {
57
+ const entries = await parseSessionFile(session.path);
58
+ const branch = getActiveBranchEntries(entries);
59
+ const turns = extractCacheTurnEntries(branch);
60
+
61
+ if (turns.length === 0) continue;
62
+
63
+ sessionsScanned++;
64
+ turnsAnalyzed += turns.length;
65
+
66
+ const toolWindows = extractToolCallWindows(branch, lookback);
67
+ const findings = buildFindings(session.id, turns, toolWindows, regressionThreshold);
68
+
69
+ // Apply idle-time reclassification before adding to the pool
70
+ detectIdleRegressions(findings, idleThreshold);
71
+
72
+ allFindings.push(...findings);
73
+ } catch {
74
+ // Skip unreadable or malformed session files silently
75
+ }
76
+ }
77
+
78
+ switch (options.pattern) {
79
+ case "hotspots": {
80
+ const findings = findHotspots(allFindings, minDrop);
81
+ return { pattern: "hotspots", findings, sessionsScanned, turnsAnalyzed };
82
+ }
83
+ case "breakdown": {
84
+ const bd = breakdownCauses(allFindings);
85
+ return {
86
+ pattern: "breakdown",
87
+ breakdown: bd,
88
+ sessionsScanned,
89
+ turnsAnalyzed,
90
+ };
91
+ }
92
+ case "correlate": {
93
+ const findings = correlateTools(allFindings);
94
+ return { pattern: "correlate", findings, sessionsScanned, turnsAnalyzed };
95
+ }
96
+ case "idle": {
97
+ const findings = allFindings.filter((f) => f.cause.type === "idle");
98
+ return { pattern: "idle", findings, sessionsScanned, turnsAnalyzed };
99
+ }
100
+ default: {
101
+ return { pattern: options.pattern, sessionsScanned, turnsAnalyzed };
102
+ }
103
+ }
104
+ }
105
+
106
+ /** Build findings from a single session's turns. */
107
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: regression detection requires multiple condition checks
108
+ export function buildFindings(
109
+ sessionId: string,
110
+ turns: import("../monitor/state.ts").TurnRecord[],
111
+ toolWindows: Map<number, import("./types.ts").ToolCallShape[]>,
112
+ regressionThreshold: number,
113
+ ): ForensicsFinding[] {
114
+ const findings: ForensicsFinding[] = [];
115
+
116
+ for (let i = 0; i < turns.length; i++) {
117
+ const turn = turns[i];
118
+ const prevTurn = findPreviousComparableTurn(turns, i);
119
+
120
+ if (turn.hitRate === undefined) continue;
121
+
122
+ const drop = prevTurn?.hitRate !== undefined ? prevTurn.hitRate - turn.hitRate : 0;
123
+
124
+ // Persisted-cause regressions (compaction, model_change, prompt_change) are
125
+ // always included. Unknown-cause drops are only included when they exceed the
126
+ // configured regression threshold.
127
+ const resolvedCause = resolveTurnCause(turn);
128
+ const hasPersistedCause = resolvedCause !== undefined && resolvedCause.type !== "unknown";
129
+
130
+ if (!hasPersistedCause && drop <= regressionThreshold) {
131
+ continue;
132
+ }
133
+
134
+ const gapMs = prevTurn !== undefined ? turn.timestamp - prevTurn.timestamp : 0;
135
+ const idleGapMinutes = gapMs > 0 ? Math.round(gapMs / 1000 / 60) : undefined;
136
+
137
+ const toolsBefore = toolWindows.get(turn.turnIndex) ?? [];
138
+ const { pathsInvolved, commandSummaries } = extractHumanDetail(toolsBefore);
139
+
140
+ findings.push({
141
+ sessionId,
142
+ turnIndex: turn.turnIndex,
143
+ previousRate: prevTurn?.hitRate,
144
+ currentRate: turn.hitRate,
145
+ drop,
146
+ cause: resolvedCause ?? { type: "unknown" },
147
+ toolsBefore,
148
+ idleGapMinutes,
149
+ ...(pathsInvolved.length > 0 ? { _pathsInvolved: pathsInvolved } : {}),
150
+ ...(commandSummaries.length > 0 ? { _commandSummaries: commandSummaries } : {}),
151
+ });
152
+ }
153
+
154
+ return findings;
155
+ }
156
+
157
+ /**
158
+ * Param keys on `write`/`edit` tools that are expected to hold file paths.
159
+ *
160
+ * Tracks PI's tool API surface. If PI changes these param names, update this
161
+ * constant to keep `_pathsInvolved` extraction working.
162
+ */
163
+ const PATH_PARAM_KEYS = ["file_path", "path"];
164
+
165
+ /** Extract file paths and command summaries from preceding tool calls. */
166
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-tool-type extraction logic
167
+ function extractHumanDetail(tools: import("./types.ts").ToolCallShape[]): {
168
+ pathsInvolved: string[];
169
+ commandSummaries: string[];
170
+ } {
171
+ const pathsInvolved: string[] = [];
172
+ const commandSummaries: string[] = [];
173
+
174
+ for (const tool of tools) {
175
+ if (tool.toolName === "write" || tool.toolName === "edit") {
176
+ const pathKey = tool.paramKeys.find((k) => PATH_PARAM_KEYS.includes(k));
177
+ if (pathKey) {
178
+ const shape = tool.paramShapes[pathKey];
179
+ if (shape?.kind === "string" && shape.len > 0) {
180
+ pathsInvolved.push(`[${tool.toolName}] ${pathKey}`);
181
+ }
182
+ }
183
+ }
184
+ if (tool.toolName === "bash") {
185
+ const shape = tool.paramShapes.command;
186
+ if (shape?.kind === "string" && shape.len > 0) {
187
+ commandSummaries.push(`bash(${shape.len} chars${shape.multiline ? ", multiline" : ""})`);
188
+ }
189
+ }
190
+ }
191
+
192
+ return { pathsInvolved, commandSummaries };
193
+ }
194
+
195
+ /** Parse a duration string like "7d", "24h", "30m" into milliseconds. */
196
+ export function parseDuration(dur: string): number {
197
+ const match = dur.match(/^(\d+)([dhm])$/i);
198
+ if (!match) {
199
+ // Default to 7 days for unparseable input
200
+ return 7 * 24 * 60 * 60 * 1000;
201
+ }
202
+ const value = Number.parseInt(match[1], 10);
203
+ const unit = match[2].toLowerCase();
204
+ switch (unit) {
205
+ case "d":
206
+ return value * 24 * 60 * 60 * 1000;
207
+ case "h":
208
+ return value * 60 * 60 * 1000;
209
+ case "m":
210
+ return value * 60 * 1000;
211
+ default:
212
+ return 7 * 24 * 60 * 60 * 1000;
213
+ }
214
+ }
@@ -0,0 +1,74 @@
1
+ // Forensics query functions — pure operations on extracted session data.
2
+
3
+ import type { CauseBreakdown, ForensicsFinding } from "./types.ts";
4
+
5
+ /**
6
+ * Rank regression turns by hit-rate drop magnitude.
7
+ *
8
+ * Only includes turns with a computable drop (both current and previous
9
+ * turns have defined hitRate). Results are sorted descending by drop.
10
+ */
11
+ export function findHotspots(
12
+ findings: ForensicsFinding[],
13
+ minDrop: number = 0,
14
+ ): ForensicsFinding[] {
15
+ return findings.filter((f) => f.drop >= minDrop).sort((a, b) => b.drop - a.drop);
16
+ }
17
+
18
+ /**
19
+ * Tally regression causes across all findings.
20
+ *
21
+ * Includes derived `idle` causes that have already been annotated by
22
+ * `detectIdleRegressions`.
23
+ */
24
+ export function breakdownCauses(findings: ForensicsFinding[]): CauseBreakdown {
25
+ const breakdown: CauseBreakdown = {
26
+ compaction: 0,
27
+ model_change: 0,
28
+ prompt_change: 0,
29
+ unknown: 0,
30
+ idle: 0,
31
+ };
32
+
33
+ for (const f of findings) {
34
+ const key = f.cause.type as keyof CauseBreakdown;
35
+ if (key in breakdown) {
36
+ breakdown[key]++;
37
+ }
38
+ }
39
+
40
+ return breakdown;
41
+ }
42
+
43
+ /**
44
+ * Attach preceding tool-call shapes to each regression finding.
45
+ *
46
+ * `toolWindows` is a per-session Map from turnIndex to the ToolCallShape[]
47
+ * extracted from the N assistant messages before that turn.
48
+ */
49
+ export function correlateTools(findings: ForensicsFinding[]): ForensicsFinding[] {
50
+ // toolWindows are already attached during extraction; this query just
51
+ // filters findings to those that have toolsBefore data.
52
+ return findings.filter((f) => f.toolsBefore.length > 0);
53
+ }
54
+
55
+ /**
56
+ * Reclassify `unknown`-cause regressions as `idle` when the inter-turn gap
57
+ * exceeds the configured threshold.
58
+ *
59
+ * Mutates findings in place for efficiency (call on a copy if immutability
60
+ * is required).
61
+ */
62
+ export function detectIdleRegressions(
63
+ findings: ForensicsFinding[],
64
+ thresholdMinutes: number,
65
+ ): ForensicsFinding[] {
66
+ for (const f of findings) {
67
+ if (f.cause.type !== "unknown") continue;
68
+ if (f.idleGapMinutes === undefined) continue;
69
+ if (f.idleGapMinutes >= thresholdMinutes) {
70
+ f.cause = { type: "idle", idleGapMinutes: f.idleGapMinutes };
71
+ }
72
+ }
73
+ return findings;
74
+ }
@@ -0,0 +1,61 @@
1
+ // Redaction utilities — shape fingerprints and human-detail stripping.
2
+
3
+ import type { ForensicsFinding, ParamShape, ToolCallShape } from "./types.ts";
4
+
5
+ /**
6
+ * Compute a structural shape fingerprint for a tool call.
7
+ *
8
+ * Captures param keys, types, lengths, and multiline status — enough for
9
+ * pattern detection ("bash calls with pipes precede cache drops") without
10
+ * exposing raw file paths or command text to the agent.
11
+ */
12
+ export function computeToolCallShape(
13
+ toolName: string,
14
+ args: Record<string, unknown>,
15
+ ): ToolCallShape {
16
+ const paramKeys = Object.keys(args);
17
+ const paramShapes: Record<string, ParamShape> = {};
18
+
19
+ for (const key of paramKeys) {
20
+ paramShapes[key] = computeParamShape(args[key]);
21
+ }
22
+
23
+ return { toolName, paramKeys, paramShapes };
24
+ }
25
+
26
+ function computeParamShape(value: unknown): ParamShape {
27
+ if (typeof value === "string") {
28
+ return {
29
+ kind: "string",
30
+ len: value.length,
31
+ multiline: value.includes("\n"),
32
+ };
33
+ }
34
+ if (typeof value === "number") {
35
+ return { kind: "number" };
36
+ }
37
+ if (typeof value === "boolean") {
38
+ return { kind: "boolean" };
39
+ }
40
+ if (Array.isArray(value)) {
41
+ return { kind: "array", len: value.length };
42
+ }
43
+ if (value !== null && typeof value === "object") {
44
+ return { kind: "object", keyCount: Object.keys(value).length };
45
+ }
46
+ // Fallback for null / undefined / function / symbol
47
+ return { kind: "string", len: 0, multiline: false };
48
+ }
49
+
50
+ /**
51
+ * Strip human-only `_prefixed` fields from findings before returning to agent.
52
+ *
53
+ * Returns a shallow copy with `_pathsInvolved` and `_commandSummaries` removed.
54
+ * Nested objects (e.g., `toolsBefore` arrays) are shared references.
55
+ */
56
+ export function stripHumanDetail(findings: ForensicsFinding[]): ForensicsFinding[] {
57
+ return findings.map((f) => {
58
+ const { _pathsInvolved: _p, _commandSummaries: _c, ...rest } = f;
59
+ return rest;
60
+ });
61
+ }
@@ -0,0 +1,59 @@
1
+ // Forensics types — cross-session cache investigation data structures.
2
+
3
+ import type { RegressionCause } from "../monitor/state.ts";
4
+
5
+ /** A forensics cause extends runtime causes with the derived "idle" classification. */
6
+ export type ForensicsCause = RegressionCause | { type: "idle"; idleGapMinutes: number };
7
+
8
+ /** A single finding from a regression turn across any session. */
9
+ export interface ForensicsFinding {
10
+ sessionId: string;
11
+ turnIndex: number;
12
+ previousRate: number | undefined;
13
+ currentRate: number | undefined;
14
+ drop: number;
15
+ cause: ForensicsCause;
16
+ toolsBefore: ToolCallShape[];
17
+ /** Inter-turn gap in minutes (computed during extraction, used by idle detection). */
18
+ idleGapMinutes?: number;
19
+ /** Human-only detail — stripped before returning to agent. */
20
+ _pathsInvolved?: string[];
21
+ /** Human-only detail — stripped before returning to agent. */
22
+ _commandSummaries?: string[];
23
+ }
24
+
25
+ /** Tally of regression causes across scanned sessions. */
26
+ export interface CauseBreakdown {
27
+ compaction: number;
28
+ model_change: number;
29
+ prompt_change: number;
30
+ unknown: number;
31
+ idle: number;
32
+ }
33
+
34
+ /** Structural fingerprint of a tool call (no raw content). */
35
+ export interface ToolCallShape {
36
+ toolName: string;
37
+ paramKeys: string[];
38
+ paramShapes: Record<string, ParamShape>;
39
+ }
40
+
41
+ /** Shape descriptor for a single parameter value. */
42
+ export type ParamShape =
43
+ | { kind: "string"; len: number; multiline: boolean }
44
+ | { kind: "number" }
45
+ | { kind: "boolean" }
46
+ | { kind: "object"; keyCount: number }
47
+ | { kind: "array"; len: number };
48
+
49
+ /** Options for running a forensics query. */
50
+ export interface ForensicsOptions {
51
+ pattern: "hotspots" | "breakdown" | "correlate" | "idle";
52
+ since: string;
53
+ minDrop?: number;
54
+ maxSessions?: number;
55
+ lookback?: number;
56
+ idleThresholdMinutes?: number;
57
+ /** Percentage-point drop threshold for classifying unknown-cause drops. Default: 25 */
58
+ regressionThreshold?: number;
59
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,16 @@
1
+ // Fast non-crypto hash for system prompt change detection.
2
+ // Uses FNV-1a for speed — not cryptographic, just collision-resistant enough
3
+ // for detecting prompt text changes between consecutive turns.
4
+
5
+ /**
6
+ * Compute a fast non-cryptographic hash of a string.
7
+ * Uses the FNV-1a algorithm (32-bit).
8
+ */
9
+ export function fastHash(str: string): number {
10
+ let hash = 0x811c9dc5; // FNV offset basis
11
+ for (let i = 0; i < str.length; i++) {
12
+ hash ^= str.charCodeAt(i);
13
+ hash = Math.imul(hash, 0x01000193); // FNV prime
14
+ }
15
+ return hash >>> 0; // ensure unsigned 32-bit
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./monitor/monitor.ts";