pi-blackhole 0.2.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/LICENSE +21 -0
- package/README.md +373 -0
- package/example-config.json +115 -0
- package/index.ts +39 -0
- package/package.json +55 -0
- package/src/commands/memory.ts +191 -0
- package/src/commands/pi-vcc.ts +94 -0
- package/src/commands/vcc-recall.ts +112 -0
- package/src/core/brief.ts +390 -0
- package/src/core/build-sections.ts +85 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +76 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +41 -0
- package/src/core/normalize.ts +79 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +56 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +227 -0
- package/src/core/settings.ts +34 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +213 -0
- package/src/core/tool-args.ts +14 -0
- package/src/core/unified-config.ts +285 -0
- package/src/details.ts +13 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/hooks/before-compact.ts +345 -0
- package/src/om/agents/dropper/agent.ts +204 -0
- package/src/om/agents/dropper/prompts.ts +48 -0
- package/src/om/agents/observer/agent.ts +256 -0
- package/src/om/agents/observer/prompts.ts +119 -0
- package/src/om/agents/reflector/agent.ts +161 -0
- package/src/om/agents/reflector/prompts.ts +77 -0
- package/src/om/clipboard.ts +63 -0
- package/src/om/compaction-hook.ts +63 -0
- package/src/om/compaction-trigger.ts +92 -0
- package/src/om/config.ts +22 -0
- package/src/om/consolidation.ts +514 -0
- package/src/om/cooldown.ts +130 -0
- package/src/om/debug-log.ts +55 -0
- package/src/om/ids.ts +5 -0
- package/src/om/ledger/fold.ts +106 -0
- package/src/om/ledger/index.ts +6 -0
- package/src/om/ledger/progress.ts +225 -0
- package/src/om/ledger/projection.ts +237 -0
- package/src/om/ledger/recall.ts +243 -0
- package/src/om/ledger/render-summary.ts +44 -0
- package/src/om/ledger/types.ts +206 -0
- package/src/om/model-budget.ts +9 -0
- package/src/om/pending.ts +225 -0
- package/src/om/reverse-recall.ts +130 -0
- package/src/om/runtime.ts +241 -0
- package/src/om/serialize.ts +224 -0
- package/src/om/tokens.ts +33 -0
- package/src/sections.ts +18 -0
- package/src/tools/recall.ts +212 -0
- package/src/types.ts +19 -0
- package/vitest.config.ts +41 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-vcc settings — reads from unified pi-blackhole/pi-blackhole-config.json.
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://github.com/sting8k/pi-vcc (src/core/settings.ts)
|
|
5
|
+
* Modified by pi-vcc-om: loadSettings wraps loadUnifiedConfig.
|
|
6
|
+
*/
|
|
7
|
+
import { loadUnifiedConfig, scaffoldConfig } from "./unified-config.js";
|
|
8
|
+
|
|
9
|
+
export interface PiVccSettings {
|
|
10
|
+
/** When true, pi-vcc handles all compactions. */
|
|
11
|
+
overrideDefaultCompaction: boolean;
|
|
12
|
+
/** Write debug snapshots to /tmp/pi-blackhole-debug.json. */
|
|
13
|
+
debug: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_SETTINGS: PiVccSettings = {
|
|
17
|
+
overrideDefaultCompaction: false,
|
|
18
|
+
debug: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function loadSettings(): PiVccSettings {
|
|
22
|
+
const config = loadUnifiedConfig(process.cwd());
|
|
23
|
+
return {
|
|
24
|
+
overrideDefaultCompaction: config.overrideDefaultCompaction,
|
|
25
|
+
debug: config.debug,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function scaffoldSettings(): void {
|
|
30
|
+
scaffoldConfig();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// For compatibility with code that imports saveSettings
|
|
34
|
+
export { loadUnifiedConfig as loadConfig } from "./unified-config.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Shared skill-tag collapse utilities */
|
|
2
|
+
|
|
3
|
+
const SKILL_TAG_RE = /^-?\s*<skill\s+name="([^"]+)"/;
|
|
4
|
+
const SKILL_CLOSE_RE = /^-?\s*<\/skill>/;
|
|
5
|
+
|
|
6
|
+
/** Collapse skill tags in an array of lines — dedup by name, drop all content inside block */
|
|
7
|
+
export const collapseSkillLines = (lines: string[]): string[] => {
|
|
8
|
+
const result: string[] = [];
|
|
9
|
+
const seenSkills = new Set<string>();
|
|
10
|
+
let insideSkill = false;
|
|
11
|
+
|
|
12
|
+
for (const line of lines) {
|
|
13
|
+
const skillMatch = line.match(SKILL_TAG_RE);
|
|
14
|
+
if (skillMatch) {
|
|
15
|
+
insideSkill = true;
|
|
16
|
+
const name = skillMatch[1];
|
|
17
|
+
if (!seenSkills.has(name)) {
|
|
18
|
+
seenSkills.add(name);
|
|
19
|
+
result.push(`[skill: ${name}]`);
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (insideSkill) {
|
|
24
|
+
if (SKILL_CLOSE_RE.test(line)) insideSkill = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
result.push(line);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Collapse <skill name="X" ...>...</skill> blocks in raw text */
|
|
33
|
+
const SKILL_BLOCK_RE = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?(?:<\/skill>|$)/g;
|
|
34
|
+
export const collapseSkillText = (text: string): string =>
|
|
35
|
+
text.replace(SKILL_BLOCK_RE, (_, name) => `[skill: ${name}]`);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-vcc compile entry — orchestrates normalization → noise filtering → section building.
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://github.com/sting8k/pi-vcc (src/core/summarize.ts)
|
|
5
|
+
* Unmodified.
|
|
6
|
+
*/
|
|
7
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
8
|
+
import type { FileOps } from "../types";
|
|
9
|
+
import { normalize } from "./normalize";
|
|
10
|
+
import { filterNoise } from "./filter-noise";
|
|
11
|
+
import { buildSections } from "./build-sections";
|
|
12
|
+
import { formatSummary, capBrief, RECALL_NOTE, wrapLongLines } from "./format";
|
|
13
|
+
|
|
14
|
+
export interface CompileInput {
|
|
15
|
+
messages: Message[];
|
|
16
|
+
previousSummary?: string;
|
|
17
|
+
fileOps?: FileOps;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const HEADER_NAMES = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
|
|
21
|
+
|
|
22
|
+
const SEPARATOR = "\n\n---\n\n";
|
|
23
|
+
|
|
24
|
+
/** Extract a named section from summary text */
|
|
25
|
+
const sectionOf = (text: string, header: string): string => {
|
|
26
|
+
const tag = `[${header}]`;
|
|
27
|
+
const start = text.indexOf(tag);
|
|
28
|
+
if (start < 0) return "";
|
|
29
|
+
const after = text.slice(start);
|
|
30
|
+
// Find next section header or separator
|
|
31
|
+
const nextSection = HEADER_NAMES
|
|
32
|
+
.filter((h) => h !== header)
|
|
33
|
+
.map((h) => after.indexOf(`[${h}]`))
|
|
34
|
+
.filter((n) => n > 0);
|
|
35
|
+
const nextSep = after.indexOf("\n\n---\n\n");
|
|
36
|
+
const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
|
|
37
|
+
const end = candidates[0];
|
|
38
|
+
return (end ? after.slice(0, end) : after).trim();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Extract the brief transcript part (everything after ---) */
|
|
42
|
+
const briefOf = (text: string): string => {
|
|
43
|
+
const idx = text.indexOf(SEPARATOR);
|
|
44
|
+
if (idx < 0) return "";
|
|
45
|
+
return text.slice(idx + SEPARATOR.length).trim();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Merge a header section */
|
|
49
|
+
const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
|
|
50
|
+
// Outstanding Context is volatile -- always use fresh only
|
|
51
|
+
if (header === "Outstanding Context") return fresh;
|
|
52
|
+
if (!prev) return fresh;
|
|
53
|
+
if (!fresh) return prev;
|
|
54
|
+
|
|
55
|
+
// Files And Changes: merge by category (Modified/Created/Read), dedup paths
|
|
56
|
+
if (header === "Files And Changes") {
|
|
57
|
+
return mergeFileLines(prev, fresh);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Session Goal, User Preferences: line-level dedup, cap
|
|
61
|
+
const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
|
|
62
|
+
const prevLines = prev.split("\n").filter(isClean);
|
|
63
|
+
const freshLines = fresh.split("\n").filter(isClean);
|
|
64
|
+
const combined = [...new Set([...prevLines, ...freshLines])];
|
|
65
|
+
const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
|
|
66
|
+
const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
|
|
67
|
+
if (capped.length === 0) return "";
|
|
68
|
+
return `[${header}]\n${capped.join("\n")}`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Merge Files And Changes by category, dedup paths across compactions */
|
|
72
|
+
const mergeFileLines = (prev: string, fresh: string): string => {
|
|
73
|
+
const categories = ["Modified", "Created", "Read"] as const;
|
|
74
|
+
const merged: Record<string, Set<string>> = {};
|
|
75
|
+
for (const cat of categories) merged[cat] = new Set();
|
|
76
|
+
|
|
77
|
+
// Parse "- Modified: a, b, c (+N more)" lines from both prev and fresh
|
|
78
|
+
for (const text of [prev, fresh]) {
|
|
79
|
+
for (const line of text.split("\n")) {
|
|
80
|
+
for (const cat of categories) {
|
|
81
|
+
const prefix = `- ${cat}: `;
|
|
82
|
+
if (!line.startsWith(prefix)) continue;
|
|
83
|
+
let rest = line.slice(prefix.length);
|
|
84
|
+
// Strip "(+N more)" suffix
|
|
85
|
+
rest = rest.replace(/\s*\(\+\d+ more\)\s*$/, "");
|
|
86
|
+
for (const p of rest.split(",")) {
|
|
87
|
+
const trimmed = p.trim();
|
|
88
|
+
if (trimmed) merged[cat].add(trimmed);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Dedup: if already in Modified, drop from Created (file existed before)
|
|
95
|
+
for (const p of merged.Modified) merged.Created.delete(p);
|
|
96
|
+
|
|
97
|
+
const cap = (set: Set<string>, limit: number) => {
|
|
98
|
+
const arr = [...set];
|
|
99
|
+
if (arr.length <= limit) return arr.join(", ");
|
|
100
|
+
return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
if (merged.Modified.size > 0) lines.push(`- Modified: ${cap(merged.Modified, 10)}`);
|
|
105
|
+
if (merged.Created.size > 0) lines.push(`- Created: ${cap(merged.Created, 10)}`);
|
|
106
|
+
if (merged.Read.size > 0) lines.push(`- Read: ${cap(merged.Read, 10)}`);
|
|
107
|
+
if (lines.length === 0) return "";
|
|
108
|
+
return `[Files And Changes]\n${lines.join("\n")}`;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const mergeBriefTranscript = (prev: string, fresh: string): string => {
|
|
112
|
+
if (!prev) return fresh;
|
|
113
|
+
if (!fresh) return prev;
|
|
114
|
+
return prev + "\n\n" + fresh;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const mergePrevious = (prev: string, fresh: string): string => {
|
|
118
|
+
// Merge header sections
|
|
119
|
+
const headers = HEADER_NAMES
|
|
120
|
+
.map((header) => {
|
|
121
|
+
const freshSec = sectionOf(fresh, header);
|
|
122
|
+
const prevSec = sectionOf(prev, header);
|
|
123
|
+
return mergeHeaderSection(header, prevSec, freshSec);
|
|
124
|
+
})
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
|
|
127
|
+
// Merge brief transcript
|
|
128
|
+
const prevBrief = briefOf(prev);
|
|
129
|
+
const freshBrief = briefOf(fresh);
|
|
130
|
+
const mergedBrief = mergeBriefTranscript(prevBrief, freshBrief);
|
|
131
|
+
|
|
132
|
+
const parts: string[] = [];
|
|
133
|
+
if (headers.length > 0) {
|
|
134
|
+
parts.push(headers.join("\n\n"));
|
|
135
|
+
}
|
|
136
|
+
if (mergedBrief) {
|
|
137
|
+
parts.push(capBrief(mergedBrief));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return parts.join(SEPARATOR);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const compile = (input: CompileInput): string => {
|
|
144
|
+
const blocks = filterNoise(normalize(input.messages));
|
|
145
|
+
const data = buildSections({ blocks });
|
|
146
|
+
const fresh = formatSummary(data);
|
|
147
|
+
// Strip any legacy RECALL_NOTE baked into prev summary (pre-fix format)
|
|
148
|
+
// so merge doesn't re-stack it inside the brief.
|
|
149
|
+
// Also strip OM content (## Reflections / ## Observations) from previous
|
|
150
|
+
// compactions — these are re-rendered fresh by the before-compact hook.
|
|
151
|
+
let prev = input.previousSummary
|
|
152
|
+
? stripRecallNote(input.previousSummary)
|
|
153
|
+
: undefined;
|
|
154
|
+
prev = prev ? stripOMContent(prev) : undefined;
|
|
155
|
+
const merged = prev ? mergePrevious(prev, fresh) : fresh;
|
|
156
|
+
if (!merged) return "";
|
|
157
|
+
return wrapLongLines(merged);
|
|
158
|
+
// RECALL_NOTE is now part of OM_FOOTER in render-summary.ts, appended
|
|
159
|
+
// by the before-compact hook after observations/reflections.
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const stripRecallNote = (text: string): string => {
|
|
163
|
+
// Remove trailing RECALL_NOTE (and any separators surrounding it) if present.
|
|
164
|
+
// Handles both current format (---\n\nNOTE) and bare trailing NOTE.
|
|
165
|
+
const idx = text.lastIndexOf(RECALL_NOTE);
|
|
166
|
+
if (idx < 0) return text;
|
|
167
|
+
return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Strip OM content (## Reflections, ## Observations, and the
|
|
172
|
+
* CONTEXT_USAGE_INSTRUCTIONS preamble) from a previous compaction summary.
|
|
173
|
+
*
|
|
174
|
+
* OM content is appended after compile() by the before-compact hook, so it
|
|
175
|
+
* must be stripped from the previous summary to prevent compounding across
|
|
176
|
+
* compactions. The fresh OM projection is re-rendered each time.
|
|
177
|
+
*/
|
|
178
|
+
const stripOMContent = (text: string): string => {
|
|
179
|
+
// Remove everything from "## Reflections" or "## Observations" onward,
|
|
180
|
+
// plus the instructions preamble that precedes them.
|
|
181
|
+
// The preamble starts with "These are condensed memories from earlier in this session."
|
|
182
|
+
const reflIdx = text.indexOf("## Reflections");
|
|
183
|
+
const obsIdx = text.indexOf("## Observations");
|
|
184
|
+
|
|
185
|
+
// Find the start of OM content: either the instructions preamble or the first section header
|
|
186
|
+
let stripFrom = -1;
|
|
187
|
+
if (reflIdx >= 0 || obsIdx >= 0) {
|
|
188
|
+
const preambleIdx = text.indexOf("These are condensed memories from earlier in this session.");
|
|
189
|
+
const minSectionIdx = Math.min(
|
|
190
|
+
reflIdx >= 0 ? reflIdx : Infinity,
|
|
191
|
+
obsIdx >= 0 ? obsIdx : Infinity,
|
|
192
|
+
);
|
|
193
|
+
// Old format: preamble before sections -> strip from preamble.
|
|
194
|
+
// New format: preamble after sections -> strip from first section header.
|
|
195
|
+
if (preambleIdx >= 0 && preambleIdx < minSectionIdx) {
|
|
196
|
+
stripFrom = preambleIdx;
|
|
197
|
+
} else if (minSectionIdx < Infinity) {
|
|
198
|
+
stripFrom = minSectionIdx;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (stripFrom < 0) return text;
|
|
203
|
+
|
|
204
|
+
// Also strip any trailing separators before the OM content
|
|
205
|
+
let end = stripFrom;
|
|
206
|
+
while (end > 0 && /\s/.test(text[end - 1])) end--;
|
|
207
|
+
// Strip trailing "---" separator if present
|
|
208
|
+
const beforeEnd = text.slice(0, end).trimEnd();
|
|
209
|
+
if (beforeEnd.endsWith("---")) {
|
|
210
|
+
return beforeEnd.slice(0, beforeEnd.length - 3).trimEnd();
|
|
211
|
+
}
|
|
212
|
+
return beforeEnd;
|
|
213
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const extractPath = (args: Record<string, unknown>): string | null => {
|
|
2
|
+
for (const key of ["path", "file_path", "filePath", "file"]) {
|
|
3
|
+
if (typeof args[key] === "string") return args[key] as string;
|
|
4
|
+
}
|
|
5
|
+
return null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const summarizeToolArgs = (args: Record<string, unknown>): string => {
|
|
9
|
+
const path = extractPath(args);
|
|
10
|
+
if (path) return `path=${path}`;
|
|
11
|
+
if (typeof args.command === "string") return `command=${args.command}`;
|
|
12
|
+
if (typeof args.query === "string") return `query=${args.query}`;
|
|
13
|
+
return Object.keys(args).join(", ");
|
|
14
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified configuration loader — merges pi-vcc + OM settings into one file.
|
|
3
|
+
*
|
|
4
|
+
* Created by pi-vcc-om.
|
|
5
|
+
* Reads ~/.pi/agent/pi-blackhole/pi-blackhole-config.json with legacy fallback support.
|
|
6
|
+
* Model configs support cooldownHours and fallbackModel arrays.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
12
|
+
|
|
13
|
+
// ── Config path ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const CONFIG_DIR = "pi-blackhole";
|
|
16
|
+
const CONFIG_FILE = "pi-blackhole-config.json";
|
|
17
|
+
|
|
18
|
+
function configPath(): string {
|
|
19
|
+
return join(getAgentDir(), CONFIG_DIR, CONFIG_FILE);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface OmModelConfig {
|
|
25
|
+
provider: string;
|
|
26
|
+
id: string;
|
|
27
|
+
thinking?: ModelThinkingLevel;
|
|
28
|
+
/** Cooldown duration in hours after a retryable error (429/5xx/timeout).
|
|
29
|
+
* Defaults to 1 hour when omitted. */
|
|
30
|
+
cooldownHours?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UnifiedConfig {
|
|
34
|
+
/** When true, pi-vcc handles all compactions (not just /pi-vcc). */
|
|
35
|
+
overrideDefaultCompaction: boolean;
|
|
36
|
+
/** Write debug snapshots to /tmp/pi-blackhole-debug.json. */
|
|
37
|
+
debug: boolean;
|
|
38
|
+
|
|
39
|
+
/** Token threshold for observer runs. */
|
|
40
|
+
observeAfterTokens: number;
|
|
41
|
+
/** Token threshold for reflector and dropper. */
|
|
42
|
+
reflectAfterTokens: number;
|
|
43
|
+
/** Token threshold for proactive auto-compaction. */
|
|
44
|
+
compactAfterTokens: number;
|
|
45
|
+
/** Observation pool token pressure for full fold. */
|
|
46
|
+
observationsPoolMaxTokens: number;
|
|
47
|
+
/** Max prompt tokens for reflector model input (rolling window cap). */
|
|
48
|
+
reflectorInputMaxTokens: number;
|
|
49
|
+
/** Max prompt tokens for dropper model input (rolling window cap). */
|
|
50
|
+
dropperInputMaxTokens: number;
|
|
51
|
+
/** Max source entries tokens sent to observer per chunk. */
|
|
52
|
+
observerChunkMaxTokens: number;
|
|
53
|
+
/** Shared turn cap for background memory agents. */
|
|
54
|
+
agentMaxTurns: number;
|
|
55
|
+
|
|
56
|
+
/** Base model override for all memory workers. */
|
|
57
|
+
model?: OmModelConfig;
|
|
58
|
+
/** Model override for observer (most frequent worker). */
|
|
59
|
+
observerModel?: OmModelConfig;
|
|
60
|
+
/** Model override for reflector (synthesizes durable facts). */
|
|
61
|
+
reflectorModel?: OmModelConfig;
|
|
62
|
+
/** Model override for dropper (prunes observations). */
|
|
63
|
+
dropperModel?: OmModelConfig;
|
|
64
|
+
|
|
65
|
+
/** Fallback models for observer, tried in order after primary model fails. */
|
|
66
|
+
observerFallbackModels?: OmModelConfig[];
|
|
67
|
+
/** Fallback models for reflector, tried in order after primary model fails. */
|
|
68
|
+
reflectorFallbackModels?: OmModelConfig[];
|
|
69
|
+
/** Fallback models for dropper, tried in order after primary model fails. */
|
|
70
|
+
dropperFallbackModels?: OmModelConfig[];
|
|
71
|
+
|
|
72
|
+
/** When true, observations/reflections are saved to pending.json
|
|
73
|
+
* instead of appended to the conversation. Auto-compaction is
|
|
74
|
+
* disabled. User triggers /blackhole to flush and compact. */
|
|
75
|
+
noAutoCompact: boolean;
|
|
76
|
+
/** Disables background workers and auto-compaction entirely. */
|
|
77
|
+
passive: boolean;
|
|
78
|
+
/** Enables observational memory (workers + content injection). Set to false for pi-vcc only. */
|
|
79
|
+
memory: boolean;
|
|
80
|
+
/** Writes debug JSONL to agent directory. */
|
|
81
|
+
debugLog: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Defaults ─────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export const DEFAULTS: UnifiedConfig = {
|
|
87
|
+
overrideDefaultCompaction: false,
|
|
88
|
+
debug: false,
|
|
89
|
+
|
|
90
|
+
observeAfterTokens: 10_000,
|
|
91
|
+
reflectAfterTokens: 20_000,
|
|
92
|
+
compactAfterTokens: 81_000,
|
|
93
|
+
observationsPoolMaxTokens: 20_000,
|
|
94
|
+
reflectorInputMaxTokens: 80_000,
|
|
95
|
+
dropperInputMaxTokens: 80_000,
|
|
96
|
+
observerChunkMaxTokens: 40_000,
|
|
97
|
+
agentMaxTurns: 16,
|
|
98
|
+
|
|
99
|
+
noAutoCompact: false,
|
|
100
|
+
passive: false,
|
|
101
|
+
memory: true,
|
|
102
|
+
debugLog: false,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ── Parsing helpers ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const THINKING_LEVELS: readonly string[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
108
|
+
|
|
109
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
110
|
+
return typeof v === "object" && v !== null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function nonEmptyString(v: unknown): string | undefined {
|
|
114
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isThinkingLevel(v: unknown): v is ModelThinkingLevel {
|
|
118
|
+
return typeof v === "string" && THINKING_LEVELS.includes(v);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function positiveInt(v: unknown): number | undefined {
|
|
122
|
+
return Number.isInteger(v) && typeof v === "number" && v > 0 ? v : undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseModel(v: unknown): OmModelConfig | undefined {
|
|
126
|
+
if (!isRecord(v)) return undefined;
|
|
127
|
+
const provider = nonEmptyString(v.provider);
|
|
128
|
+
const id = nonEmptyString(v.id);
|
|
129
|
+
if (!provider || !id) return undefined;
|
|
130
|
+
const model: OmModelConfig = { provider, id };
|
|
131
|
+
if (isThinkingLevel(v.thinking)) model.thinking = v.thinking;
|
|
132
|
+
const cooldown = positiveInt(v.cooldownHours);
|
|
133
|
+
if (cooldown !== undefined) model.cooldownHours = cooldown;
|
|
134
|
+
return model;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseModelArray(v: unknown): OmModelConfig[] | undefined {
|
|
138
|
+
if (!Array.isArray(v)) return undefined;
|
|
139
|
+
const parsed = v.map(parseModel).filter((m): m is OmModelConfig => m !== undefined);
|
|
140
|
+
return parsed.length > 0 ? parsed : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
|
|
144
|
+
const c: Partial<UnifiedConfig> = {};
|
|
145
|
+
|
|
146
|
+
// Booleans — pi-vcc
|
|
147
|
+
if (typeof raw.overrideDefaultCompaction === "boolean") c.overrideDefaultCompaction = raw.overrideDefaultCompaction;
|
|
148
|
+
if (typeof raw.debug === "boolean") c.debug = raw.debug;
|
|
149
|
+
|
|
150
|
+
// Booleans — om
|
|
151
|
+
if (typeof raw.noAutoCompact === "boolean") c.noAutoCompact = raw.noAutoCompact;
|
|
152
|
+
if (typeof raw.passive === "boolean") c.passive = raw.passive;
|
|
153
|
+
if (typeof raw.memory === "boolean") c.memory = raw.memory;
|
|
154
|
+
if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
|
|
155
|
+
|
|
156
|
+
// Positive integers
|
|
157
|
+
const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "agentMaxTurns"] as const;
|
|
158
|
+
for (const k of numKeys) {
|
|
159
|
+
const v = positiveInt(raw[k]);
|
|
160
|
+
if (v !== undefined) (c as Record<string, unknown>)[k] = v;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Models
|
|
164
|
+
const model = parseModel(raw.model);
|
|
165
|
+
if (model) c.model = model;
|
|
166
|
+
const obsModel = parseModel(raw.observerModel);
|
|
167
|
+
if (obsModel) c.observerModel = obsModel;
|
|
168
|
+
const refModel = parseModel(raw.reflectorModel);
|
|
169
|
+
if (refModel) c.reflectorModel = refModel;
|
|
170
|
+
const dropModel = parseModel(raw.dropperModel);
|
|
171
|
+
if (dropModel) c.dropperModel = dropModel;
|
|
172
|
+
|
|
173
|
+
// Fallback model arrays
|
|
174
|
+
const obsFallback = parseModelArray(raw.observerFallbackModels);
|
|
175
|
+
if (obsFallback) c.observerFallbackModels = obsFallback;
|
|
176
|
+
const refFallback = parseModelArray(raw.reflectorFallbackModels);
|
|
177
|
+
if (refFallback) c.reflectorFallbackModels = refFallback;
|
|
178
|
+
const dropFallback = parseModelArray(raw.dropperFallbackModels);
|
|
179
|
+
if (dropFallback) c.dropperFallbackModels = dropFallback;
|
|
180
|
+
|
|
181
|
+
return c;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Load and save ────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function readJson(path: string): Record<string, unknown> | null {
|
|
187
|
+
if (!existsSync(path)) return null;
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Load unified configuration from ~/.pi/agent/pi-blackhole/pi-blackhole-config.json.
|
|
197
|
+
* Falls back to legacy sources if the unified file doesn't exist.
|
|
198
|
+
*/
|
|
199
|
+
export function loadUnifiedConfig(cwd: string): UnifiedConfig {
|
|
200
|
+
const path = configPath();
|
|
201
|
+
let raw = readJson(path);
|
|
202
|
+
|
|
203
|
+
// Fallback to legacy sources if unified file doesn't exist
|
|
204
|
+
if (!raw) {
|
|
205
|
+
// Try legacy pi-vcc config
|
|
206
|
+
const piVccPath = join(getAgentDir(), "pi-vcc-config.json");
|
|
207
|
+
const piVccRaw = readJson(piVccPath);
|
|
208
|
+
|
|
209
|
+
// Try legacy om config from settings.json
|
|
210
|
+
const settingsPath = join(getAgentDir(), "settings.json");
|
|
211
|
+
const settingsRaw = readJson(settingsPath);
|
|
212
|
+
const omRaw = settingsRaw?.["pi-blackhole"] ?? settingsRaw?.["observational-memory"];
|
|
213
|
+
const projectSettingsPath = join(cwd, ".pi", "settings.json");
|
|
214
|
+
const projectRaw = readJson(projectSettingsPath);
|
|
215
|
+
const projectOmRaw = projectRaw?.["pi-blackhole"] ?? projectRaw?.["observational-memory"];
|
|
216
|
+
|
|
217
|
+
// Merge legacy sources
|
|
218
|
+
const merged: Record<string, unknown> = {};
|
|
219
|
+
if (piVccRaw && isRecord(piVccRaw)) Object.assign(merged, piVccRaw);
|
|
220
|
+
if (omRaw && isRecord(omRaw)) Object.assign(merged, omRaw);
|
|
221
|
+
if (projectOmRaw && isRecord(projectOmRaw)) Object.assign(merged, projectOmRaw);
|
|
222
|
+
raw = merged;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const parsed = parseConfig(raw);
|
|
226
|
+
|
|
227
|
+
// Env override
|
|
228
|
+
const envPassive = process.env.PI_BLACKHOLE_PASSIVE ?? process.env.PI_VCC_OM_PASSIVE ?? process.env.PI_OBSERVATIONAL_MEMORY_PASSIVE;
|
|
229
|
+
if (envPassive !== undefined) {
|
|
230
|
+
const v = envPassive.trim().toLowerCase();
|
|
231
|
+
if (["1", "true", "yes", "on"].includes(v)) parsed.passive = true;
|
|
232
|
+
else if (["0", "false", "no", "off"].includes(v)) parsed.passive = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { ...DEFAULTS, ...parsed };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Write settings back to disk. Preserves unknown keys.
|
|
240
|
+
*/
|
|
241
|
+
export function saveUnifiedConfig(settings: Partial<UnifiedConfig>): boolean {
|
|
242
|
+
try {
|
|
243
|
+
const path = configPath();
|
|
244
|
+
const dir = dirname(path);
|
|
245
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
246
|
+
const existing = readJson(path) ?? {};
|
|
247
|
+
const next = { ...existing, ...settings };
|
|
248
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
249
|
+
return true;
|
|
250
|
+
} catch {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Ensure ~/.pi/agent/pi-blackhole/pi-blackhole-config.json exists with defaults.
|
|
257
|
+
* If the file exists but is missing keys, fill them in.
|
|
258
|
+
*/
|
|
259
|
+
export function scaffoldConfig(): void {
|
|
260
|
+
try {
|
|
261
|
+
const path = configPath();
|
|
262
|
+
const dir = dirname(path);
|
|
263
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
264
|
+
|
|
265
|
+
if (!existsSync(path)) {
|
|
266
|
+
writeFileSync(path, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const parsed = readJson(path);
|
|
271
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
272
|
+
|
|
273
|
+
let changed = false;
|
|
274
|
+
const next: Record<string, unknown> = { ...parsed };
|
|
275
|
+
for (const [key, value] of Object.entries(DEFAULTS)) {
|
|
276
|
+
if (!(key in next)) {
|
|
277
|
+
next[key] = value;
|
|
278
|
+
changed = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
282
|
+
} catch {
|
|
283
|
+
// best-effort
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/details.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi-vcc compaction details type.
|
|
3
|
+
*
|
|
4
|
+
* Upstream: https://github.com/sting8k/pi-vcc (src/details.ts)
|
|
5
|
+
* Unmodified.
|
|
6
|
+
*/
|
|
7
|
+
export interface PiVccCompactionDetails {
|
|
8
|
+
compactor: "blackhole";
|
|
9
|
+
version: number;
|
|
10
|
+
sections: string[];
|
|
11
|
+
sourceMessageCount: number;
|
|
12
|
+
previousSummaryUsed: boolean;
|
|
13
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { NormalizedBlock } from "../types";
|
|
2
|
+
|
|
3
|
+
interface CommitInfo {
|
|
4
|
+
hash?: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const COMMIT_MSG_RE = /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\$?'((?:[^'\\]|\\.)*)')/;
|
|
9
|
+
// Match short hash from git output: "[branch hash]" or "main hash" or 7-12 hex
|
|
10
|
+
const HASH_RE = /\b([0-9a-f]{7,12})\b/;
|
|
11
|
+
|
|
12
|
+
const firstLineOf = (text: string): string => {
|
|
13
|
+
const line = text.split(/\\n|\n/)[0] ?? "";
|
|
14
|
+
return line.trim();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const cleanMessage = (msg: string): string =>
|
|
18
|
+
msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract git commits from bash tool calls (`git commit -m "..."`) and pair
|
|
22
|
+
* with hash from the immediately following tool_result.
|
|
23
|
+
*/
|
|
24
|
+
export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
|
|
25
|
+
const commits: CommitInfo[] = [];
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
28
|
+
const b = blocks[i];
|
|
29
|
+
if (b.kind !== "tool_call" || b.name !== "bash") continue;
|
|
30
|
+
const cmd = typeof b.args.command === "string" ? b.args.command : "";
|
|
31
|
+
if (!/\bgit\s+commit\b/.test(cmd)) continue;
|
|
32
|
+
const m = cmd.match(COMMIT_MSG_RE);
|
|
33
|
+
if (!m) continue;
|
|
34
|
+
const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ""));
|
|
35
|
+
if (!message) continue;
|
|
36
|
+
|
|
37
|
+
let hash: string | undefined;
|
|
38
|
+
// Look at next tool_result for hash
|
|
39
|
+
for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
|
|
40
|
+
const r = blocks[j];
|
|
41
|
+
if (r.kind !== "tool_result") continue;
|
|
42
|
+
// Common git commit output: `[branch <hash>] message` or `<branch> <hash>..<hash>`
|
|
43
|
+
const bracket = r.text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
|
|
44
|
+
if (bracket) { hash = bracket[1]; break; }
|
|
45
|
+
const range = r.text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
|
|
46
|
+
if (range) { hash = range[2]; break; }
|
|
47
|
+
const plain = r.text.match(HASH_RE);
|
|
48
|
+
if (plain) { hash = plain[1]; break; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Dedup by message+hash
|
|
52
|
+
const key = `${hash ?? ""}::${message}`;
|
|
53
|
+
if (!commits.some((c) => `${c.hash ?? ""}::${c.message}` === key)) {
|
|
54
|
+
commits.push({ hash, message });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return commits;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const formatCommits = (commits: CommitInfo[], limit = 8): string[] => {
|
|
62
|
+
const lines: string[] = [];
|
|
63
|
+
const items = commits.slice(-limit); // keep most recent
|
|
64
|
+
for (const c of items) {
|
|
65
|
+
const prefix = c.hash ? `${c.hash}: ` : "";
|
|
66
|
+
lines.push(`${prefix}${c.message}`);
|
|
67
|
+
}
|
|
68
|
+
return lines;
|
|
69
|
+
};
|