pi-blackhole 0.2.3 → 0.3.1
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 +244 -113
- package/example-config.json +6 -2
- package/index.ts +45 -0
- package/package.json +12 -5
- package/src/commands/pi-vcc.ts +18 -8
- package/src/commands/vcc-recall.ts +3 -2
- package/src/core/build-sections.ts +1 -1
- package/src/core/content.ts +70 -0
- package/src/core/drill-down.ts +273 -0
- package/src/core/format-recall.ts +107 -3
- package/src/core/load-messages.ts +3 -2
- package/src/core/recall-scope.ts +15 -4
- package/src/core/search-entries.ts +155 -10
- package/src/core/unified-config.ts +29 -17
- package/src/hooks/before-compact.ts +27 -1
- package/src/om/agents/dropper/agent.ts +16 -2
- package/src/om/agents/observer/agent.ts +22 -2
- package/src/om/agents/reflector/agent.ts +51 -2
- package/src/om/compaction-trigger.ts +59 -9
- package/src/om/consolidation.ts +0 -1
- package/src/om/cooldown.ts +11 -4
- package/src/om/debug-log.ts +5 -4
- package/src/om/pending.ts +8 -4
- package/src/tools/recall.ts +120 -44
- package/vitest.config.ts +1 -23
package/index.ts
CHANGED
|
@@ -18,6 +18,51 @@ import { registerRecallTool } from "./src/tools/recall";
|
|
|
18
18
|
import { Runtime } from "./src/om/runtime.js";
|
|
19
19
|
|
|
20
20
|
export default (pi: ExtensionAPI) => {
|
|
21
|
+
// ── Bridge: capture custom provider stream functions for jiti-loaded agents ──
|
|
22
|
+
// pi-blackhole's consolidation agents are loaded via jiti with moduleCache: false,
|
|
23
|
+
// which creates a separate pi-ai instance whose apiProviderRegistry lacks custom
|
|
24
|
+
// providers (e.g., claude-bridge registered by other extensions). This bridge stores
|
|
25
|
+
// streamSimple functions in a Symbol.for() global so agents can access them without
|
|
26
|
+
// going through pi-ai's registry.
|
|
27
|
+
//
|
|
28
|
+
// There are two mechanisms:
|
|
29
|
+
// 1. Wrap pi.registerProvider to capture streamSimple at registration time.
|
|
30
|
+
// This handles providers registered AFTER our factory runs.
|
|
31
|
+
// 2. On agent_start, scan modelRegistry.registeredProviders for any providers
|
|
32
|
+
// that registered BEFORE our factory ran (different extension load order).
|
|
33
|
+
const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
|
|
34
|
+
const providerStreams: Map<string, Function> = (globalThis as any)[PROVIDER_STREAMS_KEY] ??= new Map();
|
|
35
|
+
|
|
36
|
+
const origRegisterProvider = pi.registerProvider.bind(pi);
|
|
37
|
+
pi.registerProvider = (name: string, config: any) => {
|
|
38
|
+
if (config && config.streamSimple && config.api) {
|
|
39
|
+
providerStreams.set(config.api, config.streamSimple);
|
|
40
|
+
}
|
|
41
|
+
origRegisterProvider(name, config);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Fallback: on agent_start, capture providers that registered before our wrapper
|
|
45
|
+
// (handles the case where pi-blackhole loads after another provider extension).
|
|
46
|
+
// Uses a dedicated flag instead of checking providerStreams.size so the scan
|
|
47
|
+
// always runs once even if the wrapper already captured some providers.
|
|
48
|
+
let hasScannedFallback = false;
|
|
49
|
+
pi.on("agent_start", (_event: unknown, ctx: any) => {
|
|
50
|
+
if (hasScannedFallback) return;
|
|
51
|
+
hasScannedFallback = true
|
|
52
|
+
// modelRegistry.registeredProviders is declared private in TypeScript but is a
|
|
53
|
+
// regular JS class field at runtime. We access it via bracket notation for
|
|
54
|
+
// future-proofing against potential #private migration.
|
|
55
|
+
const registry = (ctx as any)?.modelRegistry;
|
|
56
|
+
const registered: Map<string, any> | undefined = registry?.["registeredProviders"];
|
|
57
|
+
if (registered && typeof registered.forEach === "function") {
|
|
58
|
+
registered.forEach((config: any, _name: string) => {
|
|
59
|
+
if (config && config.streamSimple && config.api && !providerStreams.has(config.api)) {
|
|
60
|
+
providerStreams.set(config.api, config.streamSimple);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
21
66
|
scaffoldSettings();
|
|
22
67
|
|
|
23
68
|
const omRuntime = new Runtime();
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-blackhole",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"packageManager": "pnpm@11.2.2",
|
|
4
5
|
"description": "Unified compaction + observational memory extension for Pi — compresses conversation context while preserving durable observations and reflections",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"main": "index.ts",
|
|
@@ -14,7 +15,8 @@
|
|
|
14
15
|
"example-config.json"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
|
-
"check": "tsc --noEmit"
|
|
18
|
+
"check": "tsc --noEmit",
|
|
19
|
+
"lint": "eslint src/"
|
|
18
20
|
},
|
|
19
21
|
"keywords": [
|
|
20
22
|
"pi-package",
|
|
@@ -53,8 +55,13 @@
|
|
|
53
55
|
"@earendil-works/pi-ai": "0.75.4",
|
|
54
56
|
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
55
57
|
"@earendil-works/pi-tui": "0.75.5",
|
|
56
|
-
"
|
|
57
|
-
"typescript": "
|
|
58
|
-
"
|
|
58
|
+
"@typescript-eslint/eslint-plugin": "8.60.0",
|
|
59
|
+
"@typescript-eslint/parser": "8.60.0",
|
|
60
|
+
"eslint": "9.39.4",
|
|
61
|
+
"husky": "9.1.7",
|
|
62
|
+
"lint-staged": "15.5.2",
|
|
63
|
+
"typebox": "1.1.38",
|
|
64
|
+
"typescript": "5.9.3",
|
|
65
|
+
"vitest": "4.1.7"
|
|
59
66
|
}
|
|
60
67
|
}
|
package/src/commands/pi-vcc.ts
CHANGED
|
@@ -35,19 +35,29 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
|
|
|
35
35
|
if (trimmed === "om-off") {
|
|
36
36
|
const saved = saveUnifiedConfig({ memory: false });
|
|
37
37
|
runtime.config.memory = false;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
if (saved) {
|
|
39
|
+
ctx.ui.notify("Observational memory disabled. Use /blackhole om-on to re-enable.", "info");
|
|
40
|
+
} else {
|
|
41
|
+
ctx.ui.notify(
|
|
42
|
+
"Failed to save config — the config file may be read-only (e.g., managed by Nix). " +
|
|
43
|
+
"Runtime state updated for this session only.",
|
|
44
|
+
"warning",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
42
47
|
return;
|
|
43
48
|
}
|
|
44
49
|
if (trimmed === "om-on") {
|
|
45
50
|
const saved = saveUnifiedConfig({ memory: true });
|
|
46
51
|
runtime.config.memory = true;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
if (saved) {
|
|
53
|
+
ctx.ui.notify("Observational memory enabled.", "info");
|
|
54
|
+
} else {
|
|
55
|
+
ctx.ui.notify(
|
|
56
|
+
"Failed to save config — the config file may be read-only (e.g., managed by Nix). " +
|
|
57
|
+
"Runtime state updated for this session only.",
|
|
58
|
+
"warning",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
51
61
|
return;
|
|
52
62
|
}
|
|
53
63
|
|
|
@@ -41,7 +41,7 @@ async function augmentWithObservations(
|
|
|
41
41
|
export const registerVccRecallCommand = (pi: ExtensionAPI) => {
|
|
42
42
|
pi.registerCommand("blackhole-recall", {
|
|
43
43
|
description:
|
|
44
|
-
"Search session history. Defaults to active lineage
|
|
44
|
+
"Search session history. Defaults to active lineage. Usage: /blackhole-recall <query> [page:N] [scope:all] [mode:file|transcript]",
|
|
45
45
|
handler: async (args: string, ctx) => {
|
|
46
46
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
47
47
|
if (!sessionFile) {
|
|
@@ -55,6 +55,7 @@ export const registerVccRecallCommand = (pi: ExtensionAPI) => {
|
|
|
55
55
|
parsed.scope === "lineage"
|
|
56
56
|
? getActiveLineageEntryIds(ctx.sessionManager)
|
|
57
57
|
: undefined;
|
|
58
|
+
const mode = parsed.mode;
|
|
58
59
|
|
|
59
60
|
if (!parsed.text) {
|
|
60
61
|
// No query: show recent entries
|
|
@@ -87,7 +88,7 @@ export const registerVccRecallCommand = (pi: ExtensionAPI) => {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
const { rendered, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
90
|
-
const allResults = searchEntries(rendered, rawMessages, query);
|
|
91
|
+
const allResults = searchEntries(rendered, rawMessages, query, undefined, mode);
|
|
91
92
|
|
|
92
93
|
const start = (page - 1) * PAGE_SIZE;
|
|
93
94
|
const pageResults = allResults.slice(start, start + PAGE_SIZE);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Unmodified.
|
|
6
6
|
*/
|
|
7
7
|
import type { NormalizedBlock } from "../types";
|
|
8
|
-
import {
|
|
8
|
+
import { clipSentence, firstLine, nonEmptyLines } from "./content";
|
|
9
9
|
import type { SectionData } from "../sections";
|
|
10
10
|
import { extractGoals } from "../extract/goals";
|
|
11
11
|
import { extractFiles } from "../extract/files";
|
package/src/core/content.ts
CHANGED
|
@@ -48,6 +48,76 @@ export const textParts = (content: Message["content"]): string[] => {
|
|
|
48
48
|
export const textOf = (content: Message["content"]): string =>
|
|
49
49
|
textParts(content).join("\n");
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Check if tool call arguments contain content-bearing data.
|
|
53
|
+
*
|
|
54
|
+
* A call is content-bearing if it has a path argument AND at least one
|
|
55
|
+
* large string/array field (content, edits, oldText, newText).
|
|
56
|
+
* This is a generic heuristic — not dependent on tool names.
|
|
57
|
+
*/
|
|
58
|
+
export const isContentBearing = (args: Record<string, unknown>): boolean => {
|
|
59
|
+
if (!args || typeof args !== "object") return false;
|
|
60
|
+
// Must have a path in one of the known keys
|
|
61
|
+
const hasPath = ["path", "filePath", "file_path", "file"].some((k) => typeof args[k] === "string");
|
|
62
|
+
if (!hasPath) return false;
|
|
63
|
+
// Must have at least one content-bearing field
|
|
64
|
+
if (typeof args.content === "string" && args.content.length > 0) return true;
|
|
65
|
+
if (Array.isArray(args.edits) && args.edits.length > 0) return true;
|
|
66
|
+
if (typeof args.oldText === "string" && args.oldText.length > 0 && !Array.isArray(args.edits)) return true;
|
|
67
|
+
if (typeof args.newText === "string" && args.newText.length > 0 && !Array.isArray(args.edits)) return true;
|
|
68
|
+
return false;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract textual content from tool call arguments (write, edit, hex_edit).
|
|
73
|
+
*
|
|
74
|
+
* Looks for content-bearing tool calls (those with a `path` argument and
|
|
75
|
+
* at least one large string/array field like `content`, `edits`, `oldText`, `newText`).
|
|
76
|
+
* Each call is capped at `maxBytesPerCall` to avoid inflating the search index.
|
|
77
|
+
*/
|
|
78
|
+
export const toolCallArgsText = (
|
|
79
|
+
content: Message["content"],
|
|
80
|
+
maxBytesPerCall = 10_240,
|
|
81
|
+
): string => {
|
|
82
|
+
if (!content || typeof content === "string") return "";
|
|
83
|
+
const parts: string[] = [];
|
|
84
|
+
for (const part of content) {
|
|
85
|
+
if (!part || typeof part !== "object" || part.type !== "toolCall") continue;
|
|
86
|
+
const args = part.arguments as Record<string, unknown>;
|
|
87
|
+
if (!isContentBearing(args)) continue;
|
|
88
|
+
|
|
89
|
+
let extracted = "";
|
|
90
|
+
if (typeof args.content === "string") {
|
|
91
|
+
extracted += args.content.slice(0, maxBytesPerCall) + "\n";
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(args.edits)) {
|
|
94
|
+
for (const edit of args.edits) {
|
|
95
|
+
if (extracted.length >= maxBytesPerCall) break;
|
|
96
|
+
if (edit && typeof edit === "object") {
|
|
97
|
+
if (typeof edit.oldText === "string") {
|
|
98
|
+
extracted += edit.oldText.slice(0, Math.floor(maxBytesPerCall / 2)) + "\n";
|
|
99
|
+
}
|
|
100
|
+
if (extracted.length >= maxBytesPerCall) break;
|
|
101
|
+
if (typeof edit.newText === "string") {
|
|
102
|
+
extracted += edit.newText.slice(0, Math.floor(maxBytesPerCall / 2)) + "\n";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (typeof args.oldText === "string" && !Array.isArray(args.edits)) {
|
|
108
|
+
extracted += args.oldText.slice(0, maxBytesPerCall) + "\n";
|
|
109
|
+
}
|
|
110
|
+
if (typeof args.newText === "string" && !Array.isArray(args.edits)) {
|
|
111
|
+
extracted += args.newText.slice(0, maxBytesPerCall) + "\n";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (extracted) {
|
|
115
|
+
parts.push(extracted.slice(0, maxBytesPerCall));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return parts.join("\n");
|
|
119
|
+
};
|
|
120
|
+
|
|
51
121
|
/** Extract a snippet of ~`radius` chars around the first match of `term` in `text`. */
|
|
52
122
|
export const snippet = (text: string, term: string, radius = 60): string | null => {
|
|
53
123
|
const idx = text.toLowerCase().indexOf(term.toLowerCase());
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drill-down: resolve #N:path syntax to tool call file content.
|
|
3
|
+
*
|
|
4
|
+
* Phase 3 of recall-progressive-discovery.
|
|
5
|
+
* Supports: #42:auth.ts (preview), #42:auth.ts:full (full content), #42:file (auto-select).
|
|
6
|
+
*
|
|
7
|
+
* Path matching notes:
|
|
8
|
+
* - The regex uses $ anchor so lazy .+? consumes the full path — spaces, dots, and
|
|
9
|
+
* Windows drive-letter colons (C:\) all work correctly.
|
|
10
|
+
* - One edge case: a file path literally ending in ":full" (e.g. C:\file:full)
|
|
11
|
+
* would be misinterpreted as the :full flag. This is extremely unlikely.
|
|
12
|
+
* - Inline queries like "check #42:auth.ts" are NOT drill-down — the ^ anchor
|
|
13
|
+
* requires the entire query to be the drill-down pattern.
|
|
14
|
+
*/
|
|
15
|
+
import { isContentBearing } from "./content.js";
|
|
16
|
+
import { loadAllMessages } from "./load-messages.js";
|
|
17
|
+
|
|
18
|
+
// ── Types ─────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface ContentBearingCall {
|
|
21
|
+
name: string;
|
|
22
|
+
path: string;
|
|
23
|
+
content?: string;
|
|
24
|
+
oldText?: string;
|
|
25
|
+
newText?: string;
|
|
26
|
+
edits?: Array<{ oldText?: string; newText?: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Extract the file path from tool call args, checking all known keys. */
|
|
32
|
+
function extractPathFromArgs(args: Record<string, unknown>): string | null {
|
|
33
|
+
for (const key of ["path", "filePath", "file_path", "file"]) {
|
|
34
|
+
if (typeof args[key] === "string") return args[key] as string;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find content-bearing tool calls that have a `path` argument and at least
|
|
41
|
+
* one content field (content, edits, oldText, newText).
|
|
42
|
+
* Uses the shared isContentBearing() heuristic from content.ts.
|
|
43
|
+
*/
|
|
44
|
+
function findContentBearingCalls(content: unknown[]): ContentBearingCall[] {
|
|
45
|
+
if (!Array.isArray(content)) return [];
|
|
46
|
+
const results: ContentBearingCall[] = [];
|
|
47
|
+
for (const part of content) {
|
|
48
|
+
if (!part || (part as any).type !== "toolCall") continue;
|
|
49
|
+
const args = (part as any).arguments ?? {};
|
|
50
|
+
if (!isContentBearing(args)) continue;
|
|
51
|
+
const path = extractPathFromArgs(args);
|
|
52
|
+
if (!path) continue;
|
|
53
|
+
const entry: ContentBearingCall = { name: (part as any).name ?? "", path };
|
|
54
|
+
if (typeof args.content === "string") entry.content = args.content;
|
|
55
|
+
if (Array.isArray(args.edits)) {
|
|
56
|
+
entry.edits = args.edits.filter((e: unknown): e is { oldText?: string; newText?: string } => e !== null && typeof e === "object");
|
|
57
|
+
}
|
|
58
|
+
if (typeof args.oldText === "string" && !Array.isArray(args.edits)) entry.oldText = args.oldText;
|
|
59
|
+
if (typeof args.newText === "string" && !Array.isArray(args.edits)) entry.newText = args.newText;
|
|
60
|
+
results.push(entry);
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Format content for display with optional offset/limit slicing.
|
|
66
|
+
*
|
|
67
|
+
* When full=true, shows everything ignoring offset/limit.
|
|
68
|
+
* When offset/limit given, shows a window with "Lines X-Y (of Z)" header.
|
|
69
|
+
* When neither, shows preview (first 30 lines) with truncation hint.
|
|
70
|
+
*/
|
|
71
|
+
function formatToolCallContent(
|
|
72
|
+
tc: ContentBearingCall,
|
|
73
|
+
entryIndex: number,
|
|
74
|
+
options?: { full?: boolean; offset?: number; limit?: number },
|
|
75
|
+
): string {
|
|
76
|
+
let body: string;
|
|
77
|
+
if (tc.content) {
|
|
78
|
+
body = tc.content;
|
|
79
|
+
} else if (tc.edits) {
|
|
80
|
+
body = tc.edits
|
|
81
|
+
.map((e, i) => `--- edit ${i + 1} ---\n${e.oldText ?? ""}\n--- becomes ---\n${e.newText ?? ""}`)
|
|
82
|
+
.join("\n\n");
|
|
83
|
+
} else if (tc.oldText && tc.newText) {
|
|
84
|
+
body = `--- old ---\n${tc.oldText}\n--- new ---\n${tc.newText}`;
|
|
85
|
+
} else {
|
|
86
|
+
body = "(no file content found in tool call arguments)";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const full = options?.full ?? false;
|
|
90
|
+
const offset = options?.offset;
|
|
91
|
+
const limit = options?.limit;
|
|
92
|
+
const allLines = body.split("\n");
|
|
93
|
+
const totalLines = allLines.length;
|
|
94
|
+
const previewLimit = 30;
|
|
95
|
+
const MAX_FULL_BYTES = 50 * 1024;
|
|
96
|
+
|
|
97
|
+
if (full) {
|
|
98
|
+
// Full content: capped at 50KB
|
|
99
|
+
if (Buffer.byteLength(body, "utf8") > MAX_FULL_BYTES) {
|
|
100
|
+
const truncated = body.slice(0, MAX_FULL_BYTES);
|
|
101
|
+
return `File: ${tc.path}
|
|
102
|
+
Tool: ${tc.name}
|
|
103
|
+
|
|
104
|
+
${truncated}
|
|
105
|
+
|
|
106
|
+
... (${Buffer.byteLength(body, "utf8") - MAX_FULL_BYTES} more bytes — file exceeds 50KB display limit. Use #${entryIndex}:${tc.path}:${previewLimit} for next page.)`;
|
|
107
|
+
}
|
|
108
|
+
return `File: ${tc.path}
|
|
109
|
+
Tool: ${tc.name}
|
|
110
|
+
|
|
111
|
+
${body}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (offset !== undefined) {
|
|
115
|
+
// Offset-based window: show slice
|
|
116
|
+
const startLine = Math.max(0, offset);
|
|
117
|
+
const maxLines = limit ?? 30;
|
|
118
|
+
const endLine = Math.min(startLine + maxLines, totalLines);
|
|
119
|
+
const visible = allLines.slice(startLine, endLine);
|
|
120
|
+
const displayStart = startLine + 1; // 1-indexed for user display
|
|
121
|
+
|
|
122
|
+
if (visible.length === 0) {
|
|
123
|
+
return `Offset ${startLine} is beyond file length ${totalLines}. Use #${entryIndex}:${tc.path} for the first ${previewLimit} lines.`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let result = `File: ${tc.path}
|
|
127
|
+
Tool: ${tc.name}
|
|
128
|
+
Lines ${displayStart}-${endLine} (of ${totalLines}):
|
|
129
|
+
|
|
130
|
+
`;
|
|
131
|
+
result += visible.join("\n");
|
|
132
|
+
|
|
133
|
+
if (endLine < totalLines) {
|
|
134
|
+
result += `\n\n--- Use #${entryIndex}:${tc.path}:${endLine} or #${entryIndex}:${tc.path}:${endLine}:${maxLines} for next ${maxLines} lines, #${entryIndex}:${tc.path}:full for complete ---`;
|
|
135
|
+
} else if (offset > 0) {
|
|
136
|
+
result += `\n\n(End of file)`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Default preview mode: first ${previewLimit} lines
|
|
143
|
+
if (totalLines > previewLimit) {
|
|
144
|
+
const preview = allLines.slice(0, previewLimit).join("\n");
|
|
145
|
+
return `File: ${tc.path}
|
|
146
|
+
Tool: ${tc.name}
|
|
147
|
+
|
|
148
|
+
${preview}
|
|
149
|
+
|
|
150
|
+
...(${totalLines - previewLimit} more lines — use #${entryIndex}:${tc.path}:full for complete content, or #${entryIndex}:${tc.path}:${previewLimit} for next ${previewLimit} lines)`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return `File: ${tc.path}
|
|
154
|
+
Tool: ${tc.name}
|
|
155
|
+
|
|
156
|
+
${body}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Parse drill-down query ────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pattern: #N:path, #N:path:full, #N:path:offset, or #N:path:offset:limit
|
|
163
|
+
* Group 1: index number
|
|
164
|
+
* Group 2: path (consumed lazily, expanded until suffix can match)
|
|
165
|
+
* Group 3: suffix — "full", a number (offset), or "offset:limit"
|
|
166
|
+
*/
|
|
167
|
+
const DRILLDOWN_PATTERN = /^#(\d+):(.+?)(?::(full|\d+(?::\d+)?))?$/;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parse a drill-down query like #42:auth.ts or #42:auth.ts:full.
|
|
171
|
+
* Returns null if the query doesn't match the drill-down pattern.
|
|
172
|
+
*
|
|
173
|
+
* Suffixes:
|
|
174
|
+
* :full → full content (no truncation)
|
|
175
|
+
* :30 → offset 30 lines, default limit (30)
|
|
176
|
+
* :30:20 → offset 30 lines, limit 20 lines
|
|
177
|
+
* (none) → preview first 30 lines
|
|
178
|
+
*/
|
|
179
|
+
export function parseDrillDown(
|
|
180
|
+
query: string,
|
|
181
|
+
): { index: number; pathPattern: string; full: boolean; offset?: number; limit?: number } | null {
|
|
182
|
+
const match = query.match(DRILLDOWN_PATTERN);
|
|
183
|
+
if (!match) return null;
|
|
184
|
+
const index = parseInt(match[1], 10);
|
|
185
|
+
const pathPattern = match[2];
|
|
186
|
+
const suffix = match[3];
|
|
187
|
+
|
|
188
|
+
if (suffix === "full") {
|
|
189
|
+
return { index, pathPattern, full: true, offset: undefined, limit: undefined };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (suffix !== undefined) {
|
|
193
|
+
// Parse "offset" or "offset:limit"
|
|
194
|
+
const parts = suffix.split(":");
|
|
195
|
+
const offset = parseInt(parts[0], 10);
|
|
196
|
+
const limit = parts[1] !== undefined ? parseInt(parts[1], 10) : undefined;
|
|
197
|
+
if (!Number.isNaN(offset)) {
|
|
198
|
+
return { index, pathPattern, full: false, offset, limit };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { index, pathPattern, full: false, offset: undefined, limit: undefined };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Main export ───────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Expand a drill-down query (#N:path) to tool call content.
|
|
209
|
+
*
|
|
210
|
+
* Offset/limit let you page through file content incrementally (like pi's read tool):
|
|
211
|
+
* #42:auth.ts → first 30 lines (preview)
|
|
212
|
+
* #42:auth.ts:full → all content
|
|
213
|
+
* #42:auth.ts:30 → lines 31-60 (default limit 30)
|
|
214
|
+
* #42:auth.ts:30:20 → lines 31-50 (custom limit 20)
|
|
215
|
+
*
|
|
216
|
+
* @param sessionFile - Path to the JSONL session file
|
|
217
|
+
* @param entryIndex - The message index (#N)
|
|
218
|
+
* @param pathPattern - File path substring to match (or "file" keyword)
|
|
219
|
+
* @param full - If true, return complete content without truncation
|
|
220
|
+
* @param offset - Line offset (0-indexed) for windowed content
|
|
221
|
+
* @param limit - Max lines to show (default 30 for windowed, ignored if full=true)
|
|
222
|
+
* @returns Formatted content string
|
|
223
|
+
*/
|
|
224
|
+
export function expandEntryFile(
|
|
225
|
+
sessionFile: string,
|
|
226
|
+
entryIndex: number,
|
|
227
|
+
pathPattern: string,
|
|
228
|
+
full = false,
|
|
229
|
+
offset?: number,
|
|
230
|
+
limit?: number,
|
|
231
|
+
): string {
|
|
232
|
+
const { rawMessages } = loadAllMessages(sessionFile, true);
|
|
233
|
+
|
|
234
|
+
if (entryIndex < 0 || entryIndex >= rawMessages.length) {
|
|
235
|
+
return `Entry #${entryIndex} not found in session history.`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const msg = rawMessages[entryIndex];
|
|
239
|
+
const content = msg.content as unknown[];
|
|
240
|
+
const calls = findContentBearingCalls(content);
|
|
241
|
+
|
|
242
|
+
// Special case: #42:file keyword
|
|
243
|
+
if (pathPattern === "file") {
|
|
244
|
+
if (calls.length === 0) {
|
|
245
|
+
return `No file content found in entry #${entryIndex}.`;
|
|
246
|
+
}
|
|
247
|
+
if (calls.length === 1) {
|
|
248
|
+
return formatToolCallContent(calls[0], entryIndex, { full, offset, limit });
|
|
249
|
+
}
|
|
250
|
+
// Multiple content-bearing calls — list them
|
|
251
|
+
const items = calls.map((tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`);
|
|
252
|
+
return `Entry #${entryIndex} has ${calls.length} file operations:\n${items.join("\n")}\n\nUse #${entryIndex}:path to drill into a specific file.`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const matched = calls.filter((tc) => tc.path.includes(pathPattern));
|
|
256
|
+
|
|
257
|
+
if (matched.length === 0) {
|
|
258
|
+
return `No file content found in entry #${entryIndex} for "${pathPattern}".`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (matched.length > 1) {
|
|
262
|
+
// Ambiguous match — list options instead of silently picking the first
|
|
263
|
+
const items = matched.map(
|
|
264
|
+
(tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`,
|
|
265
|
+
);
|
|
266
|
+
return `Entry #${entryIndex} has ${matched.length} file operations matching "${pathPattern}":
|
|
267
|
+
${items.join("\n")}
|
|
268
|
+
|
|
269
|
+
Use #${entryIndex}:<more-specific-path> to drill into a specific file.`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return formatToolCallContent(matched[0], entryIndex, { full, offset, limit });
|
|
273
|
+
}
|
|
@@ -1,4 +1,86 @@
|
|
|
1
|
-
import type { SearchHit } from "./search-entries";
|
|
1
|
+
import type { SearchHit, FileMatch, TouchedFile } from "./search-entries";
|
|
2
|
+
|
|
3
|
+
// ── Path shortening ───────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const CWD = process.cwd();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shorten an absolute file path for display:
|
|
9
|
+
* - If within cwd, return `./relative/path`
|
|
10
|
+
* - Otherwise, show last 3 path components with `.../` prefix
|
|
11
|
+
* - Short paths (≤3 components) returned as-is
|
|
12
|
+
*/
|
|
13
|
+
export function shortPath(fullPath: string): string {
|
|
14
|
+
// Normalize backslashes to forward slashes for cross-platform path handling
|
|
15
|
+
const normalized = fullPath.replace(/\\/g, "/");
|
|
16
|
+
const cwdNormalized = CWD.replace(/\\/g, "/");
|
|
17
|
+
if (normalized.startsWith(cwdNormalized + "/")) {
|
|
18
|
+
return "." + normalized.slice(cwdNormalized.length);
|
|
19
|
+
}
|
|
20
|
+
const parts = normalized.split("/");
|
|
21
|
+
if (parts.length > 3) {
|
|
22
|
+
return ".../" + parts.slice(-3).join("/");
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── File indicator formatting ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Render one file indicator line with shortened path. */
|
|
30
|
+
function formatFileMatch(fm: FileMatch, index: number, isQuery: boolean): string {
|
|
31
|
+
const label = isQuery
|
|
32
|
+
? (fm.lineCount === 1 ? "match" : "matches")
|
|
33
|
+
: (fm.lineCount === 1 ? "line" : "lines");
|
|
34
|
+
const displayPath = shortPath(fm.path);
|
|
35
|
+
let line = ` [${fm.toolName}] ${displayPath} — ${fm.lineCount} ${label} use #${index}:${fm.path}`;
|
|
36
|
+
if (fm.snippet) {
|
|
37
|
+
line += `\n | ${fm.snippet}`;
|
|
38
|
+
}
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Touched file output ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const TOUCHED_PAGE_SIZE = 5;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format aggregated "files touched" output.
|
|
48
|
+
*/
|
|
49
|
+
export function formatTouchedOutput(
|
|
50
|
+
touched: TouchedFile[],
|
|
51
|
+
page?: number,
|
|
52
|
+
pageSize?: number,
|
|
53
|
+
): string {
|
|
54
|
+
if (touched.length === 0) {
|
|
55
|
+
return "No file operations found in session history.";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ps = pageSize ?? TOUCHED_PAGE_SIZE;
|
|
59
|
+
const totalPages = Math.ceil(touched.length / ps);
|
|
60
|
+
const currentPage = Math.max(1, page ?? 1);
|
|
61
|
+
const start = (currentPage - 1) * ps;
|
|
62
|
+
const pageFiles = touched.slice(start, start + ps);
|
|
63
|
+
|
|
64
|
+
const header = totalPages > 1
|
|
65
|
+
? `Page ${currentPage}/${totalPages} (${touched.length} total files)`
|
|
66
|
+
: `${touched.length} files touched`;
|
|
67
|
+
|
|
68
|
+
const lines = pageFiles.map((tf) => {
|
|
69
|
+
const displayPath = shortPath(tf.path);
|
|
70
|
+
const indices = tf.entries
|
|
71
|
+
.map((e) => `#${e.index} (${e.toolName})`)
|
|
72
|
+
.join(", ");
|
|
73
|
+
return ` ${displayPath} ${indices}`;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let result = `${header}:\n\n${lines.join("\n")}`;
|
|
77
|
+
|
|
78
|
+
if (currentPage < totalPages) {
|
|
79
|
+
result += `\n\n--- Use page:${currentPage + 1} for more results ---`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
2
84
|
|
|
3
85
|
export const formatRecallOutput = (
|
|
4
86
|
entries: SearchHit[],
|
|
@@ -18,9 +100,31 @@ export const formatRecallOutput = (
|
|
|
18
100
|
: `Session history (${entries.length} entries):`;
|
|
19
101
|
|
|
20
102
|
const lines = entries.map((e) => {
|
|
21
|
-
const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
|
|
22
103
|
const body = query && e.snippet ? e.snippet : e.summary;
|
|
23
|
-
|
|
104
|
+
let line = `#${e.index} [${e.role}]`;
|
|
105
|
+
|
|
106
|
+
// Entry text on next line if it's long or has fileMatches
|
|
107
|
+
if (e.fileMatches?.length) {
|
|
108
|
+
line += `\n ${body}`;
|
|
109
|
+
// Only show top 3 file matches
|
|
110
|
+
const isQuery = Boolean(query);
|
|
111
|
+
const topFileMatches = e.fileMatches.slice(0, 3);
|
|
112
|
+
for (const fm of topFileMatches) {
|
|
113
|
+
line += `\n${formatFileMatch(fm, e.index, isQuery)}`;
|
|
114
|
+
}
|
|
115
|
+
if (e.fileMatches.length > 3) {
|
|
116
|
+
line += `\n ...(${e.fileMatches.length - 3} more file matches)`;
|
|
117
|
+
}
|
|
118
|
+
} else if (e.files?.length) {
|
|
119
|
+
// Fallback: entries without fileMatches (e.g. expand-only path) still have
|
|
120
|
+
// the legacy .files array from renderMessage — show it in old format.
|
|
121
|
+
const fileSuffix = ` files:[${e.files.join(", ")}]`;
|
|
122
|
+
line += `${fileSuffix} ${body}`;
|
|
123
|
+
} else {
|
|
124
|
+
line += ` ${body}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return line;
|
|
24
128
|
});
|
|
25
129
|
|
|
26
130
|
return `${header}\n\n${lines.join("\n\n")}`;
|
|
@@ -34,9 +34,10 @@ export const loadAllMessages = (
|
|
|
34
34
|
|
|
35
35
|
const allowed = !allowedEntryIds || allowedEntryIds.has(e.id);
|
|
36
36
|
if (allowed) {
|
|
37
|
-
|
|
37
|
+
const entryId = e.id != null ? String(e.id) : "";
|
|
38
|
+
rendered.push(renderMessage(e.message, messageIndex, entryId, full));
|
|
38
39
|
rawMessages.push(e.message);
|
|
39
|
-
entryIds.push(
|
|
40
|
+
entryIds.push(entryId);
|
|
40
41
|
}
|
|
41
42
|
messageIndex++;
|
|
42
43
|
}
|
package/src/core/recall-scope.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
export type RecallScope = "lineage" | "all";
|
|
2
|
+
export type RecallMode = "hybrid" | "file" | "transcript" | "touched";
|
|
2
3
|
|
|
3
4
|
const SCOPE_RE = /\bscope:(lineage|all)\b/i;
|
|
5
|
+
const MODE_RE = /\bmode:(hybrid|file|transcript|touched)\b/i;
|
|
6
|
+
|
|
7
|
+
const VALID_MODES = new Set(["hybrid", "file", "transcript", "touched"]);
|
|
4
8
|
|
|
5
9
|
export const normalizeRecallScope = (scope?: unknown): RecallScope =>
|
|
6
10
|
typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
|
|
7
11
|
|
|
8
|
-
export const
|
|
9
|
-
|
|
12
|
+
export const normalizeRecallMode = (mode?: unknown): RecallMode =>
|
|
13
|
+
typeof mode === "string" && VALID_MODES.has(mode.toLowerCase())
|
|
14
|
+
? (mode.toLowerCase() as RecallMode)
|
|
15
|
+
: "hybrid";
|
|
16
|
+
|
|
17
|
+
export const parseRecallScope = (text: string): { scope: RecallScope; mode: RecallMode; text: string } => {
|
|
18
|
+
const scopeMatch = text.match(SCOPE_RE);
|
|
19
|
+
const modeMatch = text.match(MODE_RE);
|
|
10
20
|
return {
|
|
11
|
-
scope: normalizeRecallScope(
|
|
12
|
-
|
|
21
|
+
scope: normalizeRecallScope(scopeMatch?.[1]),
|
|
22
|
+
mode: normalizeRecallMode(modeMatch?.[1]),
|
|
23
|
+
text: text.replace(SCOPE_RE, "").replace(MODE_RE, "").replace(/\s+/g, " ").trim(),
|
|
13
24
|
};
|
|
14
25
|
};
|