omp-vcc 0.1.13 → 0.1.14
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/extensions/main.ts +92 -29
- package/extensions/vcc-core/core/compaction-chain.ts +301 -0
- package/extensions/vcc-core/core/drill-down.ts +11 -4
- package/extensions/vcc-core/core/format-recall.ts +18 -2
- package/extensions/vcc-core/core/global-indices.ts +46 -0
- package/extensions/vcc-core/core/load-messages.ts +116 -21
- package/extensions/vcc-core/core/normalize.ts +13 -13
- package/extensions/vcc-core/core/recall-budget.ts +107 -0
- package/extensions/vcc-core/core/recall-scope.ts +16 -9
- package/extensions/vcc-core/core/search-entries.ts +201 -36
- package/extensions/vcc-core/core/session-lines.ts +81 -0
- package/extensions/vcc-core/core/settings.ts +239 -99
- package/extensions/vcc-core/core/summarize.ts +8 -2
- package/extensions/vcc-core/core/token-estimate.ts +55 -0
- package/extensions/vcc-core/core/tool-output-budget.ts +217 -0
- package/extensions/vcc-core/details.ts +35 -0
- package/extensions/vcc-core/hook.ts +677 -134
- package/package.json +77 -1
- package/types.d.ts +8 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
/** Minimal persisted-session entry shape used by the global index. */
|
|
4
|
+
export interface PersistedSessionEntry {
|
|
5
|
+
type?: unknown;
|
|
6
|
+
id?: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A fail-closed mapping from unique message entry IDs to global positions. */
|
|
10
|
+
export interface GlobalIndex {
|
|
11
|
+
/** Number of persisted entries whose type is exactly "message". */
|
|
12
|
+
messageCount: number;
|
|
13
|
+
/** Unique, non-empty string message IDs mapped to 1-based global positions. */
|
|
14
|
+
indexById: Map<string, number>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build global message positions from persisted session entries.
|
|
19
|
+
*
|
|
20
|
+
* Every message entry consumes a position, including entries without an ID.
|
|
21
|
+
* A repeated ID is removed permanently: malformed branches must not resolve
|
|
22
|
+
* to either occurrence. Missing and non-string IDs never enter the map.
|
|
23
|
+
*/
|
|
24
|
+
export const buildGlobalIndex = (
|
|
25
|
+
entries: Iterable<PersistedSessionEntry>,
|
|
26
|
+
): GlobalIndex => {
|
|
27
|
+
const indexById = new Map<string, number>();
|
|
28
|
+
const seenIds = new Set<string>();
|
|
29
|
+
let messageCount = 0;
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (entry?.type !== "message") continue;
|
|
33
|
+
messageCount++;
|
|
34
|
+
|
|
35
|
+
const id = entry.id;
|
|
36
|
+
if (typeof id !== "string" || id.length === 0 || seenIds.has(id)) {
|
|
37
|
+
if (typeof id === "string" && id.length > 0) indexById.delete(id);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
seenIds.add(id);
|
|
42
|
+
indexById.set(id, messageCount);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { messageCount, indexById };
|
|
46
|
+
};
|
|
@@ -1,44 +1,139 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
|
-
import {
|
|
2
|
+
import { statSync } from "fs";
|
|
3
3
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
4
|
-
import { renderMessage, type RenderedEntry } from "./render-entries";
|
|
4
|
+
import { renderMessage, type RenderedEntry } from "./render-entries.ts";
|
|
5
|
+
import { scanSessionEntries } from "./session-lines.ts";
|
|
5
6
|
|
|
6
7
|
export interface LoadedMessages {
|
|
7
8
|
rendered: RenderedEntry[];
|
|
8
9
|
rawMessages: Message[];
|
|
10
|
+
/** Entry IDs aligned with rendered/rawMessages; missing IDs use an empty sentinel. */
|
|
11
|
+
entryIds: string[];
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
export interface LoadMessagesDebugEvent {
|
|
15
|
+
kind: "session-parse-errors";
|
|
16
|
+
sessionFile: string;
|
|
17
|
+
parseErrors: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LoadMessagesDebugSink = (event: LoadMessagesDebugEvent) => void;
|
|
21
|
+
|
|
22
|
+
const CACHE_TTL_MS = 2_000;
|
|
23
|
+
const CACHE_CAPACITY = 3;
|
|
24
|
+
|
|
25
|
+
interface CacheEntry {
|
|
26
|
+
createdAt: number;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
value: LoadedMessages;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cache = new Map<string, CacheEntry>();
|
|
32
|
+
|
|
33
|
+
const cacheKey = (
|
|
34
|
+
sessionFile: string,
|
|
35
|
+
full: boolean,
|
|
36
|
+
allowedEntryIds?: Set<string>,
|
|
37
|
+
): string => {
|
|
38
|
+
const lineage = allowedEntryIds
|
|
39
|
+
? JSON.stringify(Array.from(allowedEntryIds).sort())
|
|
40
|
+
: "*";
|
|
41
|
+
return JSON.stringify([sessionFile, full, lineage]);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const cached = (
|
|
45
|
+
key: string,
|
|
46
|
+
now: number,
|
|
47
|
+
mtimeMs: number,
|
|
48
|
+
): LoadedMessages | undefined => {
|
|
49
|
+
const hit = cache.get(key);
|
|
50
|
+
if (!hit) return undefined;
|
|
51
|
+
if (now - hit.createdAt >= CACHE_TTL_MS || hit.mtimeMs !== mtimeMs) {
|
|
52
|
+
cache.delete(key);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Refresh insertion order for the three-entry LRU.
|
|
57
|
+
cache.delete(key);
|
|
58
|
+
cache.set(key, hit);
|
|
59
|
+
return hit.value;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const remember = (key: string, value: LoadedMessages, mtimeMs: number): void => {
|
|
63
|
+
cache.delete(key);
|
|
64
|
+
cache.set(key, { createdAt: Date.now(), mtimeMs, value });
|
|
65
|
+
if (cache.size > CACHE_CAPACITY) {
|
|
66
|
+
const oldest = cache.keys().next().value;
|
|
67
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const emitParseDiagnostic = (
|
|
72
|
+
sink: LoadMessagesDebugSink | undefined,
|
|
73
|
+
sessionFile: string,
|
|
74
|
+
parseErrors: number,
|
|
75
|
+
): void => {
|
|
76
|
+
if (!sink || parseErrors === 0) return;
|
|
77
|
+
try {
|
|
78
|
+
sink({
|
|
79
|
+
kind: "session-parse-errors",
|
|
80
|
+
sessionFile: sessionFile.slice(0, 1_024),
|
|
81
|
+
parseErrors,
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// Diagnostics must never affect session loading.
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
11
88
|
export const loadAllMessages = (
|
|
12
89
|
sessionFile: string,
|
|
13
90
|
full: boolean,
|
|
14
91
|
allowedEntryIds?: Set<string>,
|
|
92
|
+
debugSink?: LoadMessagesDebugSink,
|
|
15
93
|
): LoadedMessages => {
|
|
16
|
-
|
|
94
|
+
const key = cacheKey(sessionFile, full, allowedEntryIds);
|
|
95
|
+
let mtimeMs: number | undefined;
|
|
17
96
|
try {
|
|
18
|
-
|
|
97
|
+
mtimeMs = statSync(sessionFile).mtimeMs;
|
|
19
98
|
} catch {
|
|
20
|
-
|
|
99
|
+
// The scanner below preserves the existing empty-result behavior for
|
|
100
|
+
// missing/unreadable files while distinguishing ENOENT for its own callers.
|
|
21
101
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
102
|
+
|
|
103
|
+
if (mtimeMs !== undefined) {
|
|
104
|
+
const hit = cached(key, Date.now(), mtimeMs);
|
|
105
|
+
if (hit) return hit;
|
|
26
106
|
}
|
|
107
|
+
|
|
27
108
|
const rendered: RenderedEntry[] = [];
|
|
28
109
|
const rawMessages: Message[] = [];
|
|
29
|
-
|
|
110
|
+
const entryIds: string[] = [];
|
|
30
111
|
let messageIndex = 0;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
112
|
+
let parseErrors = 0;
|
|
113
|
+
let missing = false;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const scan = scanSessionEntries<any>(sessionFile, (entry) => {
|
|
117
|
+
if (entry?.type !== "message") return;
|
|
118
|
+
|
|
119
|
+
if (entry.message) {
|
|
120
|
+
const allowed = !allowedEntryIds || allowedEntryIds.has(entry.id);
|
|
121
|
+
if (allowed) {
|
|
122
|
+
rendered.push(renderMessage(entry.message, messageIndex, full));
|
|
123
|
+
rawMessages.push(entry.message);
|
|
124
|
+
entryIds.push(typeof entry.id === "string" ? entry.id : "");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
messageIndex++;
|
|
128
|
+
});
|
|
129
|
+
missing = scan.missing;
|
|
130
|
+
parseErrors = scan.parseErrors;
|
|
131
|
+
} catch {
|
|
132
|
+
return { rendered: [], rawMessages: [], entryIds: [] };
|
|
41
133
|
}
|
|
42
134
|
|
|
43
|
-
|
|
135
|
+
emitParseDiagnostic(debugSink, sessionFile, parseErrors);
|
|
136
|
+
const value = { rendered, rawMessages, entryIds };
|
|
137
|
+
if (!missing && mtimeMs !== undefined) remember(key, value, mtimeMs);
|
|
138
|
+
return value;
|
|
44
139
|
};
|
|
@@ -4,26 +4,26 @@ import type { NormalizedBlock } from "../types";
|
|
|
4
4
|
import { textOf } from "./content";
|
|
5
5
|
import { sanitize } from "./sanitize";
|
|
6
6
|
|
|
7
|
-
const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
7
|
+
const normalizeOne = (msg: Message, msgIndex: number, sourceIndex: number | undefined): NormalizedBlock[] => {
|
|
8
8
|
if (msg.role === "user") {
|
|
9
9
|
const blocks: NormalizedBlock[] = [];
|
|
10
10
|
const text = sanitize(textOf(msg.content));
|
|
11
|
-
if (text) blocks.push({ kind: "user", text, sourceIndex
|
|
11
|
+
if (text) blocks.push({ kind: "user", text, sourceIndex });
|
|
12
12
|
if (msg.content && typeof msg.content !== "string") {
|
|
13
13
|
for (const part of msg.content) {
|
|
14
14
|
if (part.type === "image") {
|
|
15
|
-
blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex
|
|
15
|
+
blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex });
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex
|
|
19
|
+
return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex }];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
if (msg.role === "bashExecution") {
|
|
23
23
|
const cmd = (msg as any).command ?? "";
|
|
24
24
|
const out = (msg as any).output ?? "";
|
|
25
25
|
const exit = (msg as any).exitCode;
|
|
26
|
-
return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex
|
|
26
|
+
return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex }];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (msg.role === "toolResult") {
|
|
@@ -31,29 +31,29 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
|
31
31
|
kind: "tool_result",
|
|
32
32
|
name: msg.toolName,
|
|
33
33
|
text: sanitize(textOf(msg.content)),
|
|
34
|
-
sourceIndex
|
|
34
|
+
sourceIndex,
|
|
35
35
|
}];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
if (msg.role === "assistant") {
|
|
39
39
|
if (!msg.content) return [];
|
|
40
40
|
if (typeof msg.content === "string") {
|
|
41
|
-
return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex
|
|
41
|
+
return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex }];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const blocks: NormalizedBlock[] = [];
|
|
45
45
|
for (const part of msg.content) {
|
|
46
46
|
if (part.type === "text") {
|
|
47
|
-
blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex
|
|
47
|
+
blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex });
|
|
48
48
|
} else if (part.type === "thinking") {
|
|
49
49
|
const thinkingText = sanitize(part.text ?? part.thinking ?? "");
|
|
50
|
-
if (thinkingText) blocks.push({ kind: "thinking", text: thinkingText, sourceIndex
|
|
50
|
+
if (thinkingText) blocks.push({ kind: "thinking", text: thinkingText, sourceIndex });
|
|
51
51
|
} else if (part.type === "toolCall") {
|
|
52
52
|
blocks.push({
|
|
53
53
|
kind: "tool_call",
|
|
54
54
|
name: part.name,
|
|
55
55
|
args: part.arguments,
|
|
56
|
-
sourceIndex
|
|
56
|
+
sourceIndex,
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -65,14 +65,14 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
|
65
65
|
// user or assistant turn. branchSummary entries carry no content here.
|
|
66
66
|
if (msg.role === "custom") {
|
|
67
67
|
const text = sanitize(textOf(msg.content));
|
|
68
|
-
if (text) return [{ kind: "custom", text, sourceIndex
|
|
68
|
+
if (text) return [{ kind: "custom", text, sourceIndex }];
|
|
69
69
|
return [];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
return [];
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
export const normalize = (messages: Message[]): NormalizedBlock[] =>
|
|
76
|
-
messages.flatMap((msg, i) => normalizeOne(msg, i));
|
|
75
|
+
export const normalize = (messages: Message[], sourceIndices?: Array<number | undefined>): NormalizedBlock[] =>
|
|
76
|
+
messages.flatMap((msg, i) => normalizeOne(msg, i, sourceIndices ? sourceIndices[i] : i));
|
|
77
77
|
|
|
78
78
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* Pure helpers for bounding model-facing recall responses. Human-facing
|
|
4
|
+
* commands deliberately do not call this module; their existing per-entry
|
|
5
|
+
* clips remain the boundary for those surfaces.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_RECALL_RESPONSE_MAX_CHARS = 48_000;
|
|
9
|
+
export const EXPAND_FLOOR_CHARS = 2_000;
|
|
10
|
+
export const EXPAND_ENTRY_OVERHEAD = 200;
|
|
11
|
+
|
|
12
|
+
export interface RecallBudgetBlock {
|
|
13
|
+
/** Stable entry/page number used in the continuation marker. */
|
|
14
|
+
id?: string | number;
|
|
15
|
+
text: string;
|
|
16
|
+
/** Optional human label, e.g. "entry #4" or "page 2". */
|
|
17
|
+
label?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CapRecallBlocksOptions {
|
|
21
|
+
header?: string;
|
|
22
|
+
/** Used instead of `entry` in the omitted-content marker. */
|
|
23
|
+
blockKind?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Allocate a total expansion budget fairly across requested expansions.
|
|
28
|
+
* The floor is honoured whenever the requested total can afford it; smaller
|
|
29
|
+
* budgets degrade proportionally, and the result never allocates more than
|
|
30
|
+
* the supplied budget. The per-entry overhead is reserved for separators and
|
|
31
|
+
* continuation bookkeeping.
|
|
32
|
+
*/
|
|
33
|
+
export const expandAllocation = (
|
|
34
|
+
totalChars: number,
|
|
35
|
+
count: number,
|
|
36
|
+
overhead = EXPAND_ENTRY_OVERHEAD,
|
|
37
|
+
): number[] => {
|
|
38
|
+
if (!Number.isFinite(totalChars) || totalChars <= 0 || count <= 0) return [];
|
|
39
|
+
const safeCount = Math.max(1, Math.floor(count));
|
|
40
|
+
const safeOverhead = Number.isFinite(overhead) && overhead > 0 ? overhead : 0;
|
|
41
|
+
const available = Math.max(0, totalChars - safeOverhead * safeCount);
|
|
42
|
+
const share = Math.floor(available / safeCount);
|
|
43
|
+
const floorFits = share >= EXPAND_FLOOR_CHARS;
|
|
44
|
+
if (floorFits) {
|
|
45
|
+
const result = new Array<number>(safeCount).fill(EXPAND_FLOOR_CHARS);
|
|
46
|
+
let remainder = available - EXPAND_FLOOR_CHARS * safeCount;
|
|
47
|
+
for (let i = 0; i < safeCount && remainder > 0; i++) {
|
|
48
|
+
const extra = Math.min(remainder, share - EXPAND_FLOOR_CHARS);
|
|
49
|
+
result[i] += extra;
|
|
50
|
+
remainder -= extra;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
return new Array<number>(safeCount).fill(share);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const blockLabel = (block: RecallBudgetBlock, index: number, kind: string): string => {
|
|
58
|
+
if (block.label) return block.label;
|
|
59
|
+
if (block.id !== undefined) return `${kind} ${block.id}`;
|
|
60
|
+
return `${kind} ${index + 1}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const asBlocks = (blocks: string[] | RecallBudgetBlock[]): RecallBudgetBlock[] =>
|
|
64
|
+
blocks.map((block) => (typeof block === "string" ? { text: block } : block));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cap a sequence of response blocks while retaining a bounded excerpt of
|
|
68
|
+
* every requested block. A zero budget is intentionally unbounded. The
|
|
69
|
+
* continuation marker is intentionally outside the nominal budget so callers
|
|
70
|
+
* can always explain why content was omitted.
|
|
71
|
+
*/
|
|
72
|
+
export const capRecallBlocks = (
|
|
73
|
+
blocks: Array<string | RecallBudgetBlock>,
|
|
74
|
+
maxChars = DEFAULT_RECALL_RESPONSE_MAX_CHARS,
|
|
75
|
+
options: CapRecallBlocksOptions = {},
|
|
76
|
+
): string => {
|
|
77
|
+
const normalized = asBlocks(blocks);
|
|
78
|
+
if (maxChars === 0) {
|
|
79
|
+
return [options.header, ...normalized.map((b) => b.text)].filter(Boolean).join("\n\n");
|
|
80
|
+
}
|
|
81
|
+
if (normalized.length === 0) return options.header ?? "";
|
|
82
|
+
|
|
83
|
+
const limit = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : DEFAULT_RECALL_RESPONSE_MAX_CHARS;
|
|
84
|
+
const header = options.header ?? "";
|
|
85
|
+
const bodyBudget = Math.max(0, limit - header.length);
|
|
86
|
+
const allocations = expandAllocation(bodyBudget, normalized.length);
|
|
87
|
+
const kind = options.blockKind ?? "entry";
|
|
88
|
+
const output: string[] = [];
|
|
89
|
+
const omitted: string[] = [];
|
|
90
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
91
|
+
const block = normalized[i];
|
|
92
|
+
const allocation = allocations[i] ?? 0;
|
|
93
|
+
const text = block.text ?? "";
|
|
94
|
+
if (text.length <= allocation) {
|
|
95
|
+
output.push(text);
|
|
96
|
+
} else {
|
|
97
|
+
const excerpt = allocation > 0 ? text.slice(0, allocation) : "";
|
|
98
|
+
output.push(excerpt);
|
|
99
|
+
omitted.push(blockLabel(block, i, kind));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let result = [header, ...output].filter(Boolean).join("\n\n");
|
|
103
|
+
if (omitted.length > 0) {
|
|
104
|
+
result += `\n\n--- recall response capped at ${limit} characters; omitted ${kind}${omitted.length === 1 ? "" : "s"}: ${omitted.join(", ")} ---`;
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
};
|
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
export type RecallScope = "lineage" | "all";
|
|
3
|
-
export type RecallMode = "hybrid" | "touched";
|
|
3
|
+
export type RecallMode = "hybrid" | "touched" | "file";
|
|
4
4
|
|
|
5
5
|
const SCOPE_RE = /\bscope:(lineage|all)\b/i;
|
|
6
6
|
|
|
7
|
-
const VALID_MODES =
|
|
7
|
+
const VALID_MODES: Record<string, true> = { hybrid: true, touched: true, file: true };
|
|
8
8
|
|
|
9
9
|
export const normalizeRecallScope = (scope?: unknown): RecallScope =>
|
|
10
10
|
typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Normalize a mode param to a supported recall mode.
|
|
14
|
-
*
|
|
15
|
-
* search is not implemented in pi-vcc, so it is not exposed.
|
|
16
|
-
*
|
|
17
|
-
* Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
|
|
18
|
-
* k0valik — a pi-vcc derivative.
|
|
13
|
+
* Normalize a mode param to a supported recall mode. `file` searches only
|
|
14
|
+
* content-bearing file tool-call arguments and excludes shell output.
|
|
19
15
|
*/
|
|
20
16
|
export const normalizeRecallMode = (mode?: unknown): RecallMode =>
|
|
21
|
-
typeof mode === "string" && VALID_MODES
|
|
17
|
+
typeof mode === "string" && VALID_MODES[mode.toLowerCase()]
|
|
22
18
|
? (mode.toLowerCase() as RecallMode)
|
|
23
19
|
: "hybrid";
|
|
24
20
|
|
|
21
|
+
const MODE_RE = /\bmode:(hybrid|touched|file)\b/i;
|
|
22
|
+
|
|
23
|
+
/** Parse and remove a command/tool mode selector without changing query text. */
|
|
24
|
+
export const parseRecallMode = (text: string): { mode?: RecallMode; text: string } => {
|
|
25
|
+
const match = text.match(MODE_RE);
|
|
26
|
+
return {
|
|
27
|
+
mode: match?.[1] ? normalizeRecallMode(match[1]) : undefined,
|
|
28
|
+
text: text.replace(MODE_RE, "").replace(/\s+/g, " ").trim(),
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
25
32
|
export const parseRecallScope = (text: string): { scope: RecallScope; text: string } => {
|
|
26
33
|
const match = text.match(SCOPE_RE);
|
|
27
34
|
return {
|