pi-blackhole 0.3.9 → 0.4.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 +7 -1
- package/index.ts +2 -2
- package/package.json +10 -10
- package/src/commands/cleanup.ts +319 -0
- package/src/commands/memory.ts +2 -2
- package/src/commands/pi-vcc.ts +13 -4
- package/src/core/summarize.ts +30 -23
- package/src/core/unified-config.ts +39 -11
- package/src/extract/commits.ts +68 -25
- package/src/extract/goals.ts +13 -2
- package/src/hooks/before-compact.ts +5 -2
- package/src/om/agents/dropper/agent.ts +1 -1
- package/src/om/agents/observer/agent.ts +1 -1
- package/src/om/agents/reflector/agent.ts +1 -1
- package/src/om/cleanup.ts +367 -0
- package/src/om/compaction-trigger.ts +13 -20
- package/src/om/configure-overlay.ts +39 -5
- package/src/om/consolidation.ts +23 -16
- package/src/om/ledger/projection.ts +6 -1
- package/src/om/runtime.ts +58 -31
package/src/extract/commits.ts
CHANGED
|
@@ -18,41 +18,84 @@ const firstLineOf = (text: string): string => {
|
|
|
18
18
|
const cleanMessage = (msg: string): string =>
|
|
19
19
|
msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
|
|
20
20
|
|
|
21
|
+
/** Extract commit hash from git output text (tool_result or bash output). */
|
|
22
|
+
const extractHashFromOutput = (text: string): string | undefined => {
|
|
23
|
+
const bracket = text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
|
|
24
|
+
if (bracket) return bracket[1];
|
|
25
|
+
const range = text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
|
|
26
|
+
if (range) return range[2];
|
|
27
|
+
const plain = text.match(HASH_RE);
|
|
28
|
+
if (plain) return plain[1];
|
|
29
|
+
return undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Try to extract a commit message from a git commit command string. */
|
|
33
|
+
const tryExtractMessage = (cmd: string): string | undefined => {
|
|
34
|
+
if (!/\bgit\s+commit\b/.test(cmd)) return undefined;
|
|
35
|
+
const m = cmd.match(COMMIT_MSG_RE);
|
|
36
|
+
if (!m) return undefined;
|
|
37
|
+
const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
|
|
38
|
+
return message || undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
|
-
* Extract git commits from bash tool calls
|
|
23
|
-
*
|
|
42
|
+
* Extract git commits from bash tool calls, bash execution messages,
|
|
43
|
+
* and user messages that wrap bash execution output (post-convertToLlm).
|
|
44
|
+
*
|
|
45
|
+
* Handles three block kinds:
|
|
46
|
+
* - tool_call (name: "bash") — agent tool call to the bash tool
|
|
47
|
+
* - bash — pi's internal bashExecution message
|
|
48
|
+
* - user — convertToLlm wraps bashExecution as "Ran `cmd`\n```\noutput\n```"
|
|
24
49
|
*/
|
|
25
50
|
export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
|
|
26
51
|
const commits: CommitInfo[] = [];
|
|
52
|
+
const addCommit = (hash: string | undefined, message: string) => {
|
|
53
|
+
const key = `${hash ?? ""}::${message}`;
|
|
54
|
+
if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
|
|
55
|
+
commits.push({ hash, message });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
27
58
|
|
|
28
59
|
for (let i = 0; i < blocks.length; i++) {
|
|
29
60
|
const b = blocks[i];
|
|
30
|
-
if (b.kind !== "tool_call" || b.name !== "bash") continue;
|
|
31
|
-
const cmd = typeof b.args.command === "string" ? b.args.command : "";
|
|
32
|
-
if (!/\bgit\s+commit\b/.test(cmd)) continue;
|
|
33
|
-
const m = cmd.match(COMMIT_MSG_RE);
|
|
34
|
-
if (!m) continue;
|
|
35
|
-
const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
|
|
36
|
-
if (!message) continue;
|
|
37
61
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
// ── Case 1: tool_call (agent calls bash tool) ──
|
|
63
|
+
if (b.kind === "tool_call" && b.name === "bash") {
|
|
64
|
+
const cmd = (b.args && typeof b.args.command === "string") ? b.args.command : "";
|
|
65
|
+
const message = tryExtractMessage(cmd);
|
|
66
|
+
if (!message) continue;
|
|
67
|
+
|
|
68
|
+
let hash: string | undefined;
|
|
69
|
+
for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
|
|
70
|
+
const r = blocks[j];
|
|
71
|
+
if (r.kind !== "tool_result") continue;
|
|
72
|
+
hash = extractHashFromOutput(r.text);
|
|
73
|
+
if (hash) break;
|
|
74
|
+
}
|
|
75
|
+
addCommit(hash, message);
|
|
76
|
+
continue;
|
|
50
77
|
}
|
|
51
78
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
// ── Case 2: bash execution message ──
|
|
80
|
+
if (b.kind === "bash") {
|
|
81
|
+
const message = tryExtractMessage(b.command);
|
|
82
|
+
if (!message) continue;
|
|
83
|
+
const hash = extractHashFromOutput(b.output);
|
|
84
|
+
addCommit(hash, message);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Case 3: user message wrapping a bash execution (post-convertToLlm) ──
|
|
89
|
+
if (b.kind === "user") {
|
|
90
|
+
// Detect "Ran `git commit -m "..."`" pattern in user text
|
|
91
|
+
const ranCmd = b.text.match(/Ran\s+`((?:[^`\\]|\\.)*)`/);
|
|
92
|
+
if (!ranCmd) continue;
|
|
93
|
+
const message = tryExtractMessage(ranCmd[1]);
|
|
94
|
+
if (!message) continue;
|
|
95
|
+
// Extract output from the code block following the command
|
|
96
|
+
const codeBlock = b.text.match(/```\n([\s\S]*?)```/);
|
|
97
|
+
const hash = codeBlock ? extractHashFromOutput(codeBlock[1]) : undefined;
|
|
98
|
+
addCommit(hash, message);
|
|
56
99
|
}
|
|
57
100
|
}
|
|
58
101
|
|
package/src/extract/goals.ts
CHANGED
|
@@ -40,6 +40,11 @@ const isSubstantiveGoal = (text: string): boolean => {
|
|
|
40
40
|
return true;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
const FIRST_MSG_CLIP = 80;
|
|
44
|
+
|
|
45
|
+
const indexSuffix = (sourceIndex?: number): string =>
|
|
46
|
+
sourceIndex != null ? ` (#${sourceIndex})` : "";
|
|
47
|
+
|
|
43
48
|
// Test scope-change / task intent only on the leading portion of a user block
|
|
44
49
|
// so that pasted outputs below the actual instruction do not trigger matches.
|
|
45
50
|
const LEADING_CHARS = 200;
|
|
@@ -47,6 +52,7 @@ const LEADING_CHARS = 200;
|
|
|
47
52
|
export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
|
|
48
53
|
const goals: string[] = [];
|
|
49
54
|
let latestScopeChange: string[] | null = null;
|
|
55
|
+
let latestScopeIndex: number | undefined;
|
|
50
56
|
|
|
51
57
|
for (const b of blocks) {
|
|
52
58
|
if (b.kind !== "user") continue;
|
|
@@ -58,21 +64,26 @@ export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
|
|
|
58
64
|
if (lines.length === 0) continue;
|
|
59
65
|
|
|
60
66
|
if (goals.length === 0) {
|
|
61
|
-
goals.push(...lines.slice(0, 6));
|
|
67
|
+
goals.push(...lines.slice(0, 6).map((l) => clip(l, FIRST_MSG_CLIP) + indexSuffix(b.sourceIndex)));
|
|
62
68
|
continue;
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
const leading = b.text.slice(0, LEADING_CHARS);
|
|
66
72
|
if (SCOPE_CHANGE_RE.test(leading)) {
|
|
67
73
|
latestScopeChange = lines.slice(0, 3).map((l) => clip(l, MAX_GOAL_CHARS));
|
|
74
|
+
latestScopeIndex = b.sourceIndex;
|
|
68
75
|
} else if (TASK_RE.test(leading) && lines[0].length > 15) {
|
|
69
76
|
latestScopeChange = lines.slice(0, 2).map((l) => clip(l, MAX_GOAL_CHARS));
|
|
77
|
+
latestScopeIndex = b.sourceIndex;
|
|
70
78
|
}
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
// Only emit the [Scope change] marker when we actually captured bullets.
|
|
74
82
|
if (latestScopeChange && latestScopeChange.length > 0) {
|
|
75
|
-
goals.push("[Scope change]"
|
|
83
|
+
goals.push("[Scope change]");
|
|
84
|
+
for (const line of latestScopeChange) {
|
|
85
|
+
goals.push(line + indexSuffix(latestScopeIndex));
|
|
86
|
+
}
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
return goals.slice(0, 8);
|
|
@@ -240,7 +240,7 @@ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
|
|
|
240
240
|
export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime) => {
|
|
241
241
|
pi.on("session_before_compact", (event, ctx) => {
|
|
242
242
|
const { preparation, branchEntries, customInstructions } = event;
|
|
243
|
-
omRuntime.ensureConfig(ctx.cwd ?? process.cwd());
|
|
243
|
+
omRuntime.ensureConfig(ctx.cwd ?? process.cwd(), (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
244
244
|
const trace = (ev: string, d?: Record<string, unknown>) => debugLog(ev, d, omRuntime.config.debugLog === true);
|
|
245
245
|
|
|
246
246
|
trace("before_compact.enter", {
|
|
@@ -460,7 +460,10 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
|
|
|
460
460
|
const projection = buildCompactionProjection(
|
|
461
461
|
branchEntries as any[],
|
|
462
462
|
firstKeptEntryId,
|
|
463
|
-
{
|
|
463
|
+
{
|
|
464
|
+
observationsPoolMaxTokens: omRuntime.config.observationsPoolMaxTokens,
|
|
465
|
+
fullFoldAlways: omRuntime.config.fullFoldAlways,
|
|
466
|
+
},
|
|
464
467
|
);
|
|
465
468
|
omContent = renderSummary(projection.reflections, projection.observations);
|
|
466
469
|
omDetails = projection.details;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
9
9
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
10
|
import { createBridgeStreamFn } from "../../provider-stream.js";
|
|
11
|
-
import { streamSimple } from "@earendil-works/pi-ai";
|
|
11
|
+
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
12
12
|
import { Type } from "typebox";
|
|
13
13
|
import type { Static } from "typebox";
|
|
14
14
|
import { debugLog } from "../../debug-log.js";
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
10
10
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
11
11
|
import { createBridgeStreamFn } from "../../provider-stream.js";
|
|
12
|
-
import { streamSimple } from "@earendil-works/pi-ai";
|
|
12
|
+
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
14
|
import type { Static } from "typebox";
|
|
15
15
|
import { hashId } from "../../ids.js";
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
9
9
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
10
|
import { createBridgeStreamFn } from "../../provider-stream.js";
|
|
11
|
-
import { streamSimple } from "@earendil-works/pi-ai";
|
|
11
|
+
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
12
12
|
import { Type } from "typebox";
|
|
13
13
|
import type { Static } from "typebox";
|
|
14
14
|
import { hashId } from "../../ids.js";
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup utility — scans pending JSON files and cross-references against
|
|
3
|
+
* session JSONL files to find orphaned entries safe to delete.
|
|
4
|
+
*
|
|
5
|
+
* Per-session pending files under ~/.pi/agent/pi-blackhole/ accumulate when:
|
|
6
|
+
* - compaction is set to "manual" (noAutoCompact legacy) — OM outputs are
|
|
7
|
+
* buffered rather than appended to the session
|
|
8
|
+
* - sessions are forked, abandoned, or deleted — the pending files remain
|
|
9
|
+
* - stale backup files (-pending.stale.json) persist after write-safe renames
|
|
10
|
+
*
|
|
11
|
+
* Safety invariant: a pending file is ONLY orphaned if its sessionId does NOT
|
|
12
|
+
* appear in ANY session JSONL file across all known session directories.
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
15
|
+
import { join, resolve, sep } from "node:path";
|
|
16
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface PendingFile {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
filename: string;
|
|
23
|
+
path: string;
|
|
24
|
+
/** True for -pending.stale.json (backup of previous write). */
|
|
25
|
+
isStale: boolean;
|
|
26
|
+
sizeBytes: number;
|
|
27
|
+
/** Last modified timestamp (epoch ms). */
|
|
28
|
+
mtimeMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CleanupReport {
|
|
32
|
+
/** All pending files found (both pending and stale). */
|
|
33
|
+
all: PendingFile[];
|
|
34
|
+
/** Files whose sessionId does not appear in any session JSONL. */
|
|
35
|
+
orphaned: PendingFile[];
|
|
36
|
+
/** Files whose sessionId was matched to an active session. */
|
|
37
|
+
active: PendingFile[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const PENDING_DIR = "pi-blackhole";
|
|
43
|
+
const PENDING_SUFFIX = "-pending.json";
|
|
44
|
+
const STALE_SUFFIX = "-pending.stale.json";
|
|
45
|
+
|
|
46
|
+
// ── Helper: extract session ID from filename ────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function extractSessionId(filename: string): string | null {
|
|
49
|
+
if (filename.endsWith(STALE_SUFFIX)) {
|
|
50
|
+
return filename.slice(0, -STALE_SUFFIX.length) || null;
|
|
51
|
+
}
|
|
52
|
+
if (filename.endsWith(PENDING_SUFFIX)) {
|
|
53
|
+
return filename.slice(0, -PENDING_SUFFIX.length) || null;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Scan pending files ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Scan the pi-blackhole directory for all *-pending.json and *-pending.stale.json
|
|
62
|
+
* files. Returns file metadata sorted by mtime (newest first).
|
|
63
|
+
*/
|
|
64
|
+
function scanPendingFiles(agentDir: string = getAgentDir()): PendingFile[] {
|
|
65
|
+
const dir = join(agentDir, PENDING_DIR);
|
|
66
|
+
if (!existsSync(dir)) return [];
|
|
67
|
+
|
|
68
|
+
const results: PendingFile[] = [];
|
|
69
|
+
let entries: string[];
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir);
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const filename of entries) {
|
|
77
|
+
const sessionId = extractSessionId(filename);
|
|
78
|
+
if (!sessionId) continue;
|
|
79
|
+
|
|
80
|
+
const filePath = join(dir, filename);
|
|
81
|
+
let stat;
|
|
82
|
+
try {
|
|
83
|
+
stat = statSync(filePath);
|
|
84
|
+
} catch {
|
|
85
|
+
continue; // file disappeared between readdir and stat
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
results.push({
|
|
89
|
+
sessionId,
|
|
90
|
+
filename,
|
|
91
|
+
path: filePath,
|
|
92
|
+
isStale: filename.endsWith(STALE_SUFFIX),
|
|
93
|
+
sizeBytes: stat.size,
|
|
94
|
+
mtimeMs: stat.mtimeMs,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Sort: newest first
|
|
99
|
+
results.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Scan session IDs from JSONL files ───────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Recursively scan a directory for .jsonl files and extract session IDs from
|
|
107
|
+
* their header line (first line: {"type":"session","id":"<uuid>",...}).
|
|
108
|
+
*/
|
|
109
|
+
function scanSessionDir(dir: string): Set<string> {
|
|
110
|
+
const ids = new Set<string>();
|
|
111
|
+
if (!existsSync(dir)) return ids;
|
|
112
|
+
|
|
113
|
+
const stack: string[] = [dir];
|
|
114
|
+
while (stack.length > 0) {
|
|
115
|
+
const current = stack.pop()!;
|
|
116
|
+
let names: string[];
|
|
117
|
+
try {
|
|
118
|
+
names = readdirSync(current);
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
const fullPath = join(current, name);
|
|
125
|
+
let st;
|
|
126
|
+
try {
|
|
127
|
+
st = statSync(fullPath);
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (st.isDirectory()) {
|
|
133
|
+
stack.push(fullPath);
|
|
134
|
+
} else if (st.isFile() && name.endsWith(".jsonl")) {
|
|
135
|
+
try {
|
|
136
|
+
const fd = readFileSync(fullPath, "utf-8");
|
|
137
|
+
const newlineIdx = fd.indexOf("\n");
|
|
138
|
+
const firstLine = newlineIdx >= 0 ? fd.slice(0, newlineIdx) : fd;
|
|
139
|
+
const header = JSON.parse(firstLine) as Record<string, unknown>;
|
|
140
|
+
if (header.type === "session" && typeof header.id === "string" && header.id.length > 0) {
|
|
141
|
+
ids.add(header.id);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// Corrupt or unreadable file — skip
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return ids;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read sessionDir override from settings.json, if any.
|
|
155
|
+
* Returns the resolved absolute path, or undefined if not set or unreadable.
|
|
156
|
+
*/
|
|
157
|
+
function readSettingsSessionDir(): string | undefined {
|
|
158
|
+
try {
|
|
159
|
+
const settingsPath = join(getAgentDir(), "settings.json");
|
|
160
|
+
if (!existsSync(settingsPath)) return undefined;
|
|
161
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf-8")) as Record<string, unknown>;
|
|
162
|
+
const dir = raw.sessionDir;
|
|
163
|
+
if (typeof dir === "string" && dir.trim().length > 0) {
|
|
164
|
+
// Expand ~ if present
|
|
165
|
+
const expanded = dir.startsWith("~") ? join(process.env.HOME ?? "/home", dir.slice(2)) : dir;
|
|
166
|
+
return resolve(expanded);
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// Unreadable settings — use default
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Find all known session directories.
|
|
176
|
+
* Always includes the default sessions dir; also includes custom sessionDir
|
|
177
|
+
* from settings.json if present and different from the default.
|
|
178
|
+
*/
|
|
179
|
+
/** Get the default sessions directory. */
|
|
180
|
+
function getDefaultSessionsDir(): string {
|
|
181
|
+
return join(getAgentDir(), "sessions");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function findSessionDirs(): string[] {
|
|
185
|
+
const dirs: string[] = [];
|
|
186
|
+
const default_ = getDefaultSessionsDir();
|
|
187
|
+
dirs.push(default_);
|
|
188
|
+
|
|
189
|
+
const custom = readSettingsSessionDir();
|
|
190
|
+
if (custom && custom !== default_ && existsSync(custom)) {
|
|
191
|
+
dirs.push(custom);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return dirs;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Collect all session IDs from all known session directories.
|
|
199
|
+
* Returns a Set of session UUIDs found in JSONL headers.
|
|
200
|
+
*/
|
|
201
|
+
function collectAllSessionIds(sessionDirs?: string[]): Set<string> {
|
|
202
|
+
const dirs = sessionDirs ?? findSessionDirs();
|
|
203
|
+
const allIds = new Set<string>();
|
|
204
|
+
for (const dir of dirs) {
|
|
205
|
+
const ids = scanSessionDir(dir);
|
|
206
|
+
for (const id of ids) allIds.add(id);
|
|
207
|
+
}
|
|
208
|
+
return allIds;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Cross-reference: find orphaned files ────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Determine which pending files are orphaned (no matching session JSONL).
|
|
215
|
+
*
|
|
216
|
+
* A pending file is orphaned when no session JSONL in any known session
|
|
217
|
+
* directory declares the same session ID in its header.
|
|
218
|
+
*
|
|
219
|
+
* Returns the full report with all files classified.
|
|
220
|
+
*/
|
|
221
|
+
function crossReference(
|
|
222
|
+
pending: PendingFile[],
|
|
223
|
+
sessionIds: Set<string>,
|
|
224
|
+
): CleanupReport {
|
|
225
|
+
const orphaned: PendingFile[] = [];
|
|
226
|
+
const active: PendingFile[] = [];
|
|
227
|
+
|
|
228
|
+
for (const pf of pending) {
|
|
229
|
+
if (sessionIds.has(pf.sessionId)) {
|
|
230
|
+
active.push(pf);
|
|
231
|
+
} else {
|
|
232
|
+
orphaned.push(pf);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { all: pending, orphaned, active };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Full pipeline: scan pending files, collect session IDs, cross-reference.
|
|
241
|
+
* Returns the cleanup report.
|
|
242
|
+
*/
|
|
243
|
+
export function analyzeOrphaned(agentDir?: string, sessionDirs?: string[]): CleanupReport {
|
|
244
|
+
const pending = scanPendingFiles(agentDir);
|
|
245
|
+
if (pending.length === 0) {
|
|
246
|
+
return { all: [], orphaned: [], active: [] };
|
|
247
|
+
}
|
|
248
|
+
const sessionIds = collectAllSessionIds(sessionDirs);
|
|
249
|
+
return crossReference(pending, sessionIds);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Deletion ────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/** Session IDs are UUIDs or similar alphanumeric+hyphen strings. */
|
|
255
|
+
const SAFE_SESSION_ID_RE = /^[a-zA-Z0-9][-a-zA-Z0-9]*$/;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Validate that resolving and joining with `sessionId` cannot escape the
|
|
259
|
+
* pi-blackhole directory. Must be called before any unlink.
|
|
260
|
+
*/
|
|
261
|
+
function validateDeletionPaths(
|
|
262
|
+
sessionId: string,
|
|
263
|
+
pendingDir: string,
|
|
264
|
+
): { ok: true; pendingPath: string; stalePath: string } | { ok: false } {
|
|
265
|
+
// sessionId must be non-empty and contain only safe filesystem characters
|
|
266
|
+
if (!sessionId || typeof sessionId !== "string") return { ok: false };
|
|
267
|
+
if (!SAFE_SESSION_ID_RE.test(sessionId)) return { ok: false };
|
|
268
|
+
|
|
269
|
+
const resolvedDir = resolve(pendingDir);
|
|
270
|
+
const pendingPath = join(resolvedDir, `${sessionId}${PENDING_SUFFIX}`);
|
|
271
|
+
const stalePath = join(resolvedDir, `${sessionId}${STALE_SUFFIX}`);
|
|
272
|
+
|
|
273
|
+
// Resolve to absolute and verify containment within pi-blackhole/
|
|
274
|
+
const resolvedPending = resolve(pendingPath);
|
|
275
|
+
const resolvedStale = resolve(stalePath);
|
|
276
|
+
|
|
277
|
+
if (!resolvedPending.startsWith(resolvedDir + sep)) return { ok: false };
|
|
278
|
+
if (!resolvedStale.startsWith(resolvedDir + sep)) return { ok: false };
|
|
279
|
+
|
|
280
|
+
return { ok: true, pendingPath: resolvedPending, stalePath: resolvedStale };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Delete all pending files (pending + stale) for a given sessionId.
|
|
285
|
+
*
|
|
286
|
+
* Safety: validates that both resolved paths live under the pi-blackhole/
|
|
287
|
+
* directory before unlinking. Returns false if validation fails.
|
|
288
|
+
*
|
|
289
|
+
* Returns true if at least one file was deleted, false if no files existed
|
|
290
|
+
* or if the paths failed containment validation.
|
|
291
|
+
*/
|
|
292
|
+
export function deletePendingFiles(sessionId: string, agentDir?: string): boolean {
|
|
293
|
+
const dir = join(agentDir ?? getAgentDir(), PENDING_DIR);
|
|
294
|
+
const valid = validateDeletionPaths(sessionId, dir);
|
|
295
|
+
if (!valid.ok) return false;
|
|
296
|
+
|
|
297
|
+
const { pendingPath, stalePath } = valid;
|
|
298
|
+
|
|
299
|
+
let deleted = false;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
if (existsSync(pendingPath)) {
|
|
303
|
+
unlinkSync(pendingPath);
|
|
304
|
+
deleted = true;
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
// Best-effort — file may be locked or already gone
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
if (existsSync(stalePath)) {
|
|
312
|
+
unlinkSync(stalePath);
|
|
313
|
+
deleted = true;
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// Best-effort
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return deleted;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Delete multiple pending files by sessionId.
|
|
324
|
+
* Returns the count of successfully deleted file sets.
|
|
325
|
+
*/
|
|
326
|
+
export function deleteOrphanedBatch(
|
|
327
|
+
orphaned: PendingFile[],
|
|
328
|
+
agentDir?: string,
|
|
329
|
+
): number {
|
|
330
|
+
let count = 0;
|
|
331
|
+
for (const pf of orphaned) {
|
|
332
|
+
if (deletePendingFiles(pf.sessionId, agentDir)) {
|
|
333
|
+
count++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return count;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Formatting helpers ──────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/** Human-readable file size. */
|
|
342
|
+
function formatSize(bytes: number): string {
|
|
343
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
344
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
345
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Human-readable age from epoch ms. */
|
|
349
|
+
function formatAge(mtimeMs: number, nowMs: number = Date.now()): string {
|
|
350
|
+
const diffMs = nowMs - mtimeMs;
|
|
351
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
352
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
353
|
+
const minutes = Math.floor(seconds / 60);
|
|
354
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
355
|
+
const hours = Math.floor(minutes / 60);
|
|
356
|
+
if (hours < 24) return `${hours}h ago`;
|
|
357
|
+
const days = Math.floor(hours / 24);
|
|
358
|
+
if (days < 365) return `${days}d ago`;
|
|
359
|
+
const years = Math.floor(days / 365);
|
|
360
|
+
return `${years}y ago`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Summary line for a pending file. */
|
|
364
|
+
export function describeFile(pf: PendingFile, nowMs?: number): string {
|
|
365
|
+
const label = pf.isStale ? "stale" : "pending";
|
|
366
|
+
return `${pf.sessionId.slice(0, 8)}… ${label} ${formatSize(pf.sizeBytes)} ${formatAge(pf.mtimeMs, nowMs)}`;
|
|
367
|
+
}
|
|
@@ -28,6 +28,9 @@ function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" |
|
|
|
28
28
|
|
|
29
29
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
30
30
|
pi.on("agent_start", () => {
|
|
31
|
+
// Reset the info gate — allow one info notification during the new turn.
|
|
32
|
+
runtime.resetInfoGate();
|
|
33
|
+
|
|
31
34
|
// A new turn is starting — abort any pending auto-compaction wait.
|
|
32
35
|
// The new turn's own agent_end will re-evaluate the threshold and
|
|
33
36
|
// schedule a fresh wait if compaction is still needed.
|
|
@@ -49,7 +52,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
52
|
-
runtime.ensureConfig(ctx.cwd);
|
|
55
|
+
runtime.ensureConfig(ctx.cwd, (msg) => ctx.ui?.notify?.(msg, "warning"));
|
|
56
|
+
// Reset the info gate — allow one notification during agent_end.
|
|
57
|
+
runtime.resetInfoGate();
|
|
53
58
|
|
|
54
59
|
// Pass the config flag explicitly — this handler runs outside ALS context
|
|
55
60
|
// (agent_end events don't flow through consolidation's withDebugLogContext),
|
|
@@ -136,12 +141,8 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
|
136
141
|
|
|
137
142
|
dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
|
|
138
143
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
ui,
|
|
142
|
-
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
|
|
143
|
-
"info",
|
|
144
|
-
);
|
|
144
|
+
runtime.tryEmitInfo(hasUI, ui,
|
|
145
|
+
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`);
|
|
145
146
|
|
|
146
147
|
runtime.compactInFlight = true;
|
|
147
148
|
const controller = new AbortController();
|
|
@@ -189,12 +190,8 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
|
189
190
|
runtime.compactInFlight = false;
|
|
190
191
|
runtime.autoCompactionController = null;
|
|
191
192
|
dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
ui,
|
|
195
|
-
"Observational memory: compaction cancelled — session changed before compaction",
|
|
196
|
-
"info",
|
|
197
|
-
);
|
|
193
|
+
runtime.tryEmitInfo(hasUI, ui,
|
|
194
|
+
"Observational memory: compaction cancelled — session changed before compaction");
|
|
198
195
|
return;
|
|
199
196
|
}
|
|
200
197
|
|
|
@@ -229,12 +226,8 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
|
229
226
|
runtime.compactInFlight = false;
|
|
230
227
|
runtime.autoCompactionController = null;
|
|
231
228
|
dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
ui,
|
|
235
|
-
"Observational memory: compaction skipped — another compaction already ran before deferred compaction",
|
|
236
|
-
"info",
|
|
237
|
-
);
|
|
229
|
+
runtime.tryEmitInfo(hasUI, ui,
|
|
230
|
+
"Observational memory: compaction skipped — another compaction already ran before deferred compaction");
|
|
238
231
|
return;
|
|
239
232
|
}
|
|
240
233
|
|
|
@@ -246,7 +239,7 @@ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
|
246
239
|
onComplete: (result: any) => {
|
|
247
240
|
runtime.compactInFlight = false;
|
|
248
241
|
dbg("compaction_trigger.onComplete", { result: !!result });
|
|
249
|
-
|
|
242
|
+
runtime.tryEmitInfo(hasUI, ui, "Observational memory: compaction complete");
|
|
250
243
|
},
|
|
251
244
|
onError: (error: { message: string }) => {
|
|
252
245
|
runtime.compactInFlight = false;
|