@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.
- package/README.md +119 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +44 -0
- package/src/config.ts +37 -0
- package/src/fingerprint.ts +187 -0
- package/src/forensics/extract.ts +129 -0
- package/src/forensics/forensics.ts +214 -0
- package/src/forensics/queries.ts +74 -0
- package/src/forensics/redact.ts +61 -0
- package/src/forensics/types.ts +59 -0
- package/src/hash.ts +16 -0
- package/src/index.ts +1 -0
- package/src/monitor/monitor.ts +320 -0
- package/src/monitor/state.ts +308 -0
- package/src/monitor/status.ts +33 -0
- package/src/report/forensics.ts +165 -0
- package/src/report/history.ts +197 -0
- 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";
|