@monotykamary/pi-vcc 0.3.16
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 +688 -0
- package/__tests__/helpers.ts +135 -0
- package/__tests__/root-causes.test.ts +858 -0
- package/demo.gif +0 -0
- package/index.ts +21 -0
- package/knip.json +4 -0
- package/npm-shrinkwrap.json +3001 -0
- package/package.json +36 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +36 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +497 -0
- package/src/core/build-sections.ts +431 -0
- package/src/core/content.ts +71 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +177 -0
- package/src/core/format.ts +98 -0
- package/src/core/invisible-continue.ts +172 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +40 -0
- package/src/core/normalize.ts +73 -0
- package/src/core/recall-scope.ts +49 -0
- package/src/core/render-entries.ts +58 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +375 -0
- package/src/core/settings.ts +190 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +179 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +19 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/shared-symbols.ts +357 -0
- package/src/hooks/before-compact.ts +564 -0
- package/src/hooks/proactive-threshold.ts +131 -0
- package/src/sections.ts +23 -0
- package/src/tools/recall.ts +204 -0
- package/src/types.ts +20 -0
- package/tests/australian-government.test.ts +479 -0
- package/tests/before-compact-hook.test.ts +185 -0
- package/tests/before-compact.test.ts +227 -0
- package/tests/brief.test.ts +215 -0
- package/tests/build-sections.test.ts +59 -0
- package/tests/compile.test.ts +89 -0
- package/tests/content.test.ts +66 -0
- package/tests/deep-extraction.test.ts +313 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +87 -0
- package/tests/format.test.ts +73 -0
- package/tests/government-domain.test.ts +542 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +49 -0
- package/tests/model-threshold-hook.test.ts +377 -0
- package/tests/model-threshold.test.ts +223 -0
- package/tests/normalize.test.ts +104 -0
- package/tests/proactive-threshold.test.ts +490 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +92 -0
- package/tests/report.test.ts +58 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +233 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tests/threshold-integration.test.ts +359 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { SearchHit } from "./search-entries";
|
|
2
|
+
|
|
3
|
+
// Roles that start a new conversation segment (turn).
|
|
4
|
+
// A segment begins at a user or bashExecution (user-initiated action)
|
|
5
|
+
// and continues through assistant responses, tool calls, and tool results
|
|
6
|
+
// until the next user/bash boundary.
|
|
7
|
+
const SEGMENT_START_ROLES = new Set(["user", "bash"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A segment is a contiguous group of entries forming one conversational turn:
|
|
11
|
+
* a user/assistant message followed by its associated tool calls, tool results,
|
|
12
|
+
* and bash executions. The next user/assistant message starts a new segment.
|
|
13
|
+
*/
|
|
14
|
+
interface RecallSegment {
|
|
15
|
+
/** Entry indices covered by this segment (e.g. "#42-#47") */
|
|
16
|
+
range: string;
|
|
17
|
+
/** All entries in this segment, in order */
|
|
18
|
+
entries: SearchHit[];
|
|
19
|
+
/** Which entries within this segment matched the query */
|
|
20
|
+
matchedIndices: Set<number>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Group entries into conversation segments (turns).
|
|
25
|
+
*
|
|
26
|
+
* A segment starts at a user or assistant message and includes all
|
|
27
|
+
* subsequent tool calls, tool results, and bash executions until
|
|
28
|
+
* the next user/assistant boundary.
|
|
29
|
+
*
|
|
30
|
+
* When entries lack explicit `snippet` markers (e.g. called from
|
|
31
|
+
* external code or tests), all entries in query mode are treated
|
|
32
|
+
* as matched.
|
|
33
|
+
*/
|
|
34
|
+
const groupSegments = (entries: SearchHit[], hasQuery: boolean): RecallSegment[] => {
|
|
35
|
+
if (entries.length === 0) return [];
|
|
36
|
+
|
|
37
|
+
// Detect whether entries have snippet markers set.
|
|
38
|
+
// If none do but we have a query, treat the entire set as matched.
|
|
39
|
+
const anySnippet = entries.some((e) => e.snippet !== undefined);
|
|
40
|
+
const assumeAllMatched = hasQuery && !anySnippet;
|
|
41
|
+
|
|
42
|
+
const segments: RecallSegment[] = [];
|
|
43
|
+
let start = 0;
|
|
44
|
+
|
|
45
|
+
for (let i = 1; i < entries.length; i++) {
|
|
46
|
+
const role = entries[i].role;
|
|
47
|
+
if (SEGMENT_START_ROLES.has(role)) {
|
|
48
|
+
const slice = entries.slice(start, i);
|
|
49
|
+
const matched = new Set<number>();
|
|
50
|
+
for (let j = 0; j < slice.length; j++) {
|
|
51
|
+
if (assumeAllMatched || slice[j].snippet) matched.add(j);
|
|
52
|
+
}
|
|
53
|
+
segments.push({
|
|
54
|
+
range: `#${slice[0].index}` +
|
|
55
|
+
(slice.length > 1 ? `-#${slice[slice.length - 1].index}` : ""),
|
|
56
|
+
entries: slice,
|
|
57
|
+
matchedIndices: matched,
|
|
58
|
+
});
|
|
59
|
+
start = i;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Last segment
|
|
64
|
+
const slice = entries.slice(start);
|
|
65
|
+
if (slice.length > 0) {
|
|
66
|
+
const matched = new Set<number>();
|
|
67
|
+
for (let j = 0; j < slice.length; j++) {
|
|
68
|
+
if (assumeAllMatched || slice[j].snippet) matched.add(j);
|
|
69
|
+
}
|
|
70
|
+
segments.push({
|
|
71
|
+
range: `#${slice[0].index}` +
|
|
72
|
+
(slice.length > 1 ? `-#${slice[slice.length - 1].index}` : ""),
|
|
73
|
+
entries: slice,
|
|
74
|
+
matchedIndices: matched,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return segments;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Format a single entry line with optional match indicator. */
|
|
82
|
+
const formatEntry = (e: SearchHit, matched: boolean): string => {
|
|
83
|
+
const prefix = matched ? ">" : " ";
|
|
84
|
+
const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
|
|
85
|
+
const body = matched && e.snippet ? e.snippet : e.summary;
|
|
86
|
+
return `${prefix} #${e.index} [${e.role}]${fileSuffix} ${body}`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Format a segment with its entries. */
|
|
90
|
+
const formatSegment = (seg: RecallSegment): string => {
|
|
91
|
+
const lines: string[] = [];
|
|
92
|
+
|
|
93
|
+
// Segment header with match count
|
|
94
|
+
const matchCount = seg.matchedIndices.size;
|
|
95
|
+
if (matchCount > 0 && matchCount < seg.entries.length) {
|
|
96
|
+
lines.push(`--- ${seg.range} (${matchCount}/${seg.entries.length} entries match) ---`);
|
|
97
|
+
} else if (matchCount > 0) {
|
|
98
|
+
lines.push(`--- ${seg.range} ---`);
|
|
99
|
+
} else {
|
|
100
|
+
lines.push(`--- ${seg.range} (context) ---`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < seg.entries.length; i++) {
|
|
104
|
+
const matched = seg.matchedIndices.has(i);
|
|
105
|
+
lines.push(formatEntry(seg.entries[i], matched));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Count total unique matches across all segments. */
|
|
112
|
+
const countMatches = (segments: RecallSegment[]): number => {
|
|
113
|
+
let total = 0;
|
|
114
|
+
for (const seg of segments) {
|
|
115
|
+
total += seg.matchedIndices.size;
|
|
116
|
+
}
|
|
117
|
+
return total;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const formatRecallOutput = (
|
|
121
|
+
entries: SearchHit[],
|
|
122
|
+
query?: string,
|
|
123
|
+
headerOverride?: string,
|
|
124
|
+
): string => {
|
|
125
|
+
if (entries.length === 0) {
|
|
126
|
+
return query
|
|
127
|
+
? `No matches for "${query}" in session history.`
|
|
128
|
+
: "No entries in session history.";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Browse mode (no query): keep flat format, no structure needed
|
|
132
|
+
if (!query) {
|
|
133
|
+
const header = headerOverride || `Session history (${entries.length} entries):`;
|
|
134
|
+
const lines = entries.map((e) => {
|
|
135
|
+
const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
|
|
136
|
+
return `#${e.index} [${e.role}]${fileSuffix} ${e.summary}`;
|
|
137
|
+
});
|
|
138
|
+
return `${header}\n\n${lines.join("\n\n")}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Search mode: group into structure-preserving segments
|
|
142
|
+
const header = headerOverride
|
|
143
|
+
? `${headerOverride} for "${query}":`
|
|
144
|
+
: `Found ${entries.length} matches for "${query}":`;
|
|
145
|
+
|
|
146
|
+
const segments = groupSegments(entries, true);
|
|
147
|
+
const matchedCount = countMatches(segments);
|
|
148
|
+
|
|
149
|
+
const prefix = segments.length > 1
|
|
150
|
+
? `${matchedCount} matches across ${segments.length} segments`
|
|
151
|
+
: `${matchedCount} matches in 1 segment`;
|
|
152
|
+
|
|
153
|
+
const formatted = segments
|
|
154
|
+
.filter((seg) => seg.matchedIndices.size > 0)
|
|
155
|
+
.map(formatSegment);
|
|
156
|
+
|
|
157
|
+
// Add adjacent non-matching segments as context around the first
|
|
158
|
+
// matched segment (so the agent sees the conversation flow).
|
|
159
|
+
if (segments.length > 1) {
|
|
160
|
+
const firstMatchIdx = segments.findIndex((s) => s.matchedIndices.size > 0);
|
|
161
|
+
if (firstMatchIdx >= 0) {
|
|
162
|
+
// Show the segment before for context (only if it has no matches itself)
|
|
163
|
+
if (firstMatchIdx > 0 && segments[firstMatchIdx - 1].matchedIndices.size === 0) {
|
|
164
|
+
formatted.unshift(formatSegment(segments[firstMatchIdx - 1]));
|
|
165
|
+
}
|
|
166
|
+
// Show the segment after for context (only if not already shown)
|
|
167
|
+
if (firstMatchIdx < segments.length - 1) {
|
|
168
|
+
const next = segments[firstMatchIdx + 1];
|
|
169
|
+
if (next.matchedIndices.size === 0) {
|
|
170
|
+
formatted.push(formatSegment(next));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return `${header} \u2014 ${prefix}\n\n${formatted.join("\n\n")}`;
|
|
177
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { SectionData } from "../sections";
|
|
2
|
+
|
|
3
|
+
const section = (title: string, items?: string[]): string => {
|
|
4
|
+
if (!items || items.length === 0) return "";
|
|
5
|
+
const body = items.map((i) => `- ${i}`).join("\n");
|
|
6
|
+
return `[${title}]\n${body}`;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const BRIEF_MAX_LINES = 120;
|
|
10
|
+
const TUI_SAFE_LINE_CHARS = 120;
|
|
11
|
+
|
|
12
|
+
const wrapLine = (line: string, maxChars: number): string[] => {
|
|
13
|
+
if (line.length <= maxChars) return [line];
|
|
14
|
+
|
|
15
|
+
const indent = line.match(/^\s*(?:[-*]\s+|\d+\.\s+)?/)?.[0] ?? "";
|
|
16
|
+
const continuationIndent = indent ? " ".repeat(Math.min(indent.length, 8)) : "";
|
|
17
|
+
const wrapped: string[] = [];
|
|
18
|
+
let remaining = line;
|
|
19
|
+
let prefix = "";
|
|
20
|
+
|
|
21
|
+
while (prefix.length + remaining.length > maxChars) {
|
|
22
|
+
const available = Math.max(20, maxChars - prefix.length);
|
|
23
|
+
let splitAt = remaining.lastIndexOf(" ", available);
|
|
24
|
+
if (splitAt < Math.floor(available * 0.5)) splitAt = available;
|
|
25
|
+
|
|
26
|
+
wrapped.push(prefix + remaining.slice(0, splitAt).trimEnd());
|
|
27
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
28
|
+
prefix = continuationIndent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (remaining) wrapped.push(prefix + remaining);
|
|
32
|
+
return wrapped;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const wrapLongLines = (text: string, maxChars = TUI_SAFE_LINE_CHARS): string =>
|
|
36
|
+
text.split("\n").flatMap((line) => wrapLine(line, maxChars)).join("\n");
|
|
37
|
+
|
|
38
|
+
export const capBrief = (text: string): string => {
|
|
39
|
+
const lines = text.split("\n");
|
|
40
|
+
if (lines.length <= BRIEF_MAX_LINES) return text;
|
|
41
|
+
const omitted = lines.length - BRIEF_MAX_LINES;
|
|
42
|
+
const kept = lines.slice(-BRIEF_MAX_LINES);
|
|
43
|
+
// Find first section header to avoid cutting mid-section
|
|
44
|
+
const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
|
|
45
|
+
const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
|
|
46
|
+
return `...(${omitted} earlier lines omitted)\n\n${clean.join("\n")}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const RECALL_NOTE =
|
|
50
|
+
"Use `vcc_recall` to search for prior work, decisions, and context from before this summary. " +
|
|
51
|
+
"Do not redo work already completed.";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format the summary with cache-friendly section ordering.
|
|
55
|
+
*
|
|
56
|
+
* Stable (merged/accumulated) sections come first so the prompt prefix
|
|
57
|
+
* stays cacheable across compactions. Volatile (always-fresh) sections
|
|
58
|
+
* come last.
|
|
59
|
+
*/
|
|
60
|
+
export const formatSummary = (
|
|
61
|
+
data: SectionData,
|
|
62
|
+
): string => {
|
|
63
|
+
// Cache-friendly ordering: stable first, volatile last
|
|
64
|
+
const stableSections = [
|
|
65
|
+
section("Session Goal", data.sessionGoal),
|
|
66
|
+
section("User Preferences", data.userPreferences),
|
|
67
|
+
section("Files And Changes", data.filesAndChanges),
|
|
68
|
+
section("Commits", data.commits),
|
|
69
|
+
section("Anchors", data.anchors),
|
|
70
|
+
].filter(Boolean);
|
|
71
|
+
|
|
72
|
+
const volatileSections = [
|
|
73
|
+
section("Type Catalog", data.typeCatalog),
|
|
74
|
+
section("Outstanding Context", data.outstandingContext),
|
|
75
|
+
section("Earlier Turns", data.turnSummaries),
|
|
76
|
+
section("Current Status", data.currentStatus),
|
|
77
|
+
].filter(Boolean);
|
|
78
|
+
|
|
79
|
+
// All header sections (stable + volatile) form the header block
|
|
80
|
+
const allHeaders = [...stableSections, ...volatileSections];
|
|
81
|
+
|
|
82
|
+
const parts: string[] = [];
|
|
83
|
+
if (allHeaders.length > 0) {
|
|
84
|
+
parts.push(allHeaders.join("\n\n"));
|
|
85
|
+
}
|
|
86
|
+
if (data.briefTranscript) {
|
|
87
|
+
parts.push(capBrief(data.briefTranscript));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (parts.length === 0) return "";
|
|
91
|
+
|
|
92
|
+
let result = wrapLongLines(parts.join("\n\n---\n\n"));
|
|
93
|
+
|
|
94
|
+
// NOTE: RECALL_NOTE is intentionally NOT appended here.
|
|
95
|
+
// It is appended once by `compile()` at the very end, after merge-with-previous,
|
|
96
|
+
// to avoid the note compounding inside the brief transcript across compactions.
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Agent } from "@earendil-works/pi-agent-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Invisible continue — resume the agent loop after compaction without
|
|
5
|
+
* injecting any message into context.
|
|
6
|
+
*
|
|
7
|
+
* Same mechanism as pi-retry: capture the live Agent instance via
|
|
8
|
+
* subscribe() monkey-patch, then call agent.prompt([]) to start a
|
|
9
|
+
* fresh agent loop that picks up where the compacted transcript left off.
|
|
10
|
+
* The LLM sees the exact same message list — no new user prompt.
|
|
11
|
+
*
|
|
12
|
+
* Used after threshold compaction when the agent would otherwise stall
|
|
13
|
+
* (willRetry=false, no queued messages).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let _agent: Agent | null = null;
|
|
17
|
+
let _continueInProgress = false;
|
|
18
|
+
|
|
19
|
+
// Timestamp of the last completed triggerInvisibleContinue().
|
|
20
|
+
// Used by the continue() monkey-patch to detect when triggerInvisibleContinue
|
|
21
|
+
// just ran (so it shouldn't fall back to prompt([]) again — the agent
|
|
22
|
+
// already continued). This prevents double continuation when both
|
|
23
|
+
// pi-retry and pi-vcc are installed.
|
|
24
|
+
let _lastInvisibleContinueTime = 0;
|
|
25
|
+
|
|
26
|
+
// Monkey-patch Agent.prototype.subscribe to capture the live instance.
|
|
27
|
+
// Chain the previous patch (if pi-retry already patched it) so both
|
|
28
|
+
// extensions can coexist.
|
|
29
|
+
const _prevSubscribe = Agent.prototype.subscribe;
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
|
|
32
|
+
_agent = this;
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
return (_prevSubscribe as any).apply(this, args);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Monkey-patch continue() so that after compaction, when the rebuilt
|
|
38
|
+
// context ends with an assistant message, the agent loop can still
|
|
39
|
+
// continue. Without this patch, the session's continue() call throws
|
|
40
|
+
// "Cannot continue from message role: assistant" and the agent loop
|
|
41
|
+
// exits — leaving mid-task work unfinished.
|
|
42
|
+
//
|
|
43
|
+
// The fix: when continue() would throw because the last message is an
|
|
44
|
+
// assistant, fall back to prompt([]) instead. prompt([]) doesn't check
|
|
45
|
+
// the last message role — it starts a fresh agent loop with the current
|
|
46
|
+
// context. This is equivalent to the invisible-continue mechanism
|
|
47
|
+
// (same call the extension fires from session_compact), but triggered
|
|
48
|
+
// from the session's own continue() path so the while loop stays alive.
|
|
49
|
+
//
|
|
50
|
+
// Chains the previous patch (pi-retry, pi-invisible-continue) so all
|
|
51
|
+
// mutexes are respected.
|
|
52
|
+
const _prevContinue = Agent.prototype.continue;
|
|
53
|
+
Agent.prototype.continue = function (this: Agent): Promise<void> {
|
|
54
|
+
const self = this;
|
|
55
|
+
return (async () => {
|
|
56
|
+
while (_continueInProgress) {
|
|
57
|
+
await new Promise(r => setTimeout(r, 10));
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return await (_prevContinue as any).call(self);
|
|
61
|
+
} catch (e: unknown) {
|
|
62
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
63
|
+
|
|
64
|
+
// After compaction, the rebuilt context often ends with an assistant
|
|
65
|
+
// message. The original continue() throws "Cannot continue from
|
|
66
|
+
// message role: assistant". Instead of swallowing the error and
|
|
67
|
+
// letting the agent loop die (which leaves mid-task work unfinished),
|
|
68
|
+
// fall back to prompt([]). This restarts the agent loop with the
|
|
69
|
+
// compacted context — the LLM picks up where it left off.
|
|
70
|
+
//
|
|
71
|
+
// prompt([]) is safe regardless of last message role. The agent
|
|
72
|
+
// processes the current context and continues.
|
|
73
|
+
//
|
|
74
|
+
// We only do this when the agent is NOT currently processing
|
|
75
|
+
// (no other triggerInvisibleContinue in flight) to avoid races.
|
|
76
|
+
if (msg.includes("Cannot continue from message role") ||
|
|
77
|
+
msg.includes("Cannot continue from an assistant message")) {
|
|
78
|
+
// Check stopReason of the last message — only continue if
|
|
79
|
+
// the agent was mid-task (not a clean stop or user abort).
|
|
80
|
+
const lastMsg = self.state.messages[self.state.messages.length - 1];
|
|
81
|
+
if (lastMsg?.role === "assistant" &&
|
|
82
|
+
lastMsg.stopReason !== "stop" &&
|
|
83
|
+
lastMsg.stopReason !== "aborted") {
|
|
84
|
+
// Agent was mid-task — fall through to prompt([])
|
|
85
|
+
// (after the error-handling block)
|
|
86
|
+
} else {
|
|
87
|
+
// Agent finished cleanly or was aborted — don't continue.
|
|
88
|
+
// The session loop should exit (task complete).
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Fall back to prompt([]) to actually continue the agent.
|
|
92
|
+
// The while loop in _runAgentPrompt stays alive because prompt([])
|
|
93
|
+
// runs the agent (which emits events, updates _lastAssistantMessage,
|
|
94
|
+
// etc.).
|
|
95
|
+
//
|
|
96
|
+
// Guard: if triggerInvisibleContinue() just completed (within the
|
|
97
|
+
// last 500ms), the agent already continued — skip the fallback to
|
|
98
|
+
// avoid double continuation. This happens when both pi-retry and
|
|
99
|
+
// pi-vcc are installed: pi-retry's triggerInvisibleContinue runs
|
|
100
|
+
// first, then the session's continue() wrapper unblocks and would
|
|
101
|
+
// fall back to prompt([]) again.
|
|
102
|
+
if (!_continueInProgress && Date.now() - _lastInvisibleContinueTime > 500) {
|
|
103
|
+
_continueInProgress = true;
|
|
104
|
+
try {
|
|
105
|
+
await self.prompt([]);
|
|
106
|
+
} catch {
|
|
107
|
+
// Agent already processing or other transient error
|
|
108
|
+
} finally {
|
|
109
|
+
_continueInProgress = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (msg.includes("Agent is already processing")) {
|
|
116
|
+
// Another extension is driving the agent — wait it out.
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function sleep(ms: number): Promise<void> {
|
|
125
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fire an invisible continue after the agent becomes idle.
|
|
130
|
+
*
|
|
131
|
+
* MUST NOT be awaited — it schedules work that runs after the current
|
|
132
|
+
* session loop iteration finishes. Awaiting would deadlock inside the
|
|
133
|
+
* session's _handlePostAgentRun → _runAutoCompaction call stack.
|
|
134
|
+
*/
|
|
135
|
+
export function triggerInvisibleContinue(): void {
|
|
136
|
+
if (!_agent) return;
|
|
137
|
+
if (_continueInProgress) return;
|
|
138
|
+
_continueInProgress = true;
|
|
139
|
+
|
|
140
|
+
const run = async () => {
|
|
141
|
+
try {
|
|
142
|
+
// Wait for the current run to finish (activeRun resolves in
|
|
143
|
+
// finishRun() after agent_end listeners return).
|
|
144
|
+
await _agent!.waitForIdle();
|
|
145
|
+
|
|
146
|
+
// Small delay to ensure the session's while-loop has exited
|
|
147
|
+
// after _handlePostAgentRun returns false.
|
|
148
|
+
await sleep(50);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
// Await so _continueInProgress stays true for the full cycle.
|
|
152
|
+
await _agent!.prompt([]);
|
|
153
|
+
} catch {
|
|
154
|
+
// Ignore — if prompt throws, something else is driving.
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
_continueInProgress = false;
|
|
158
|
+
// Record completion time so the continue() monkey-patch can
|
|
159
|
+
// detect that an invisible continue just ran and avoid firing
|
|
160
|
+
// a duplicate prompt([]) (RC7: double continuation guard).
|
|
161
|
+
_lastInvisibleContinueTime = Date.now();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
void run();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Reset state on new session. */
|
|
169
|
+
export function resetInvisibleContinue(): void {
|
|
170
|
+
_continueInProgress = false;
|
|
171
|
+
_lastInvisibleContinueTime = 0;
|
|
172
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
interface LineageEntryLike {
|
|
2
|
+
id?: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface LineageSessionManagerLike {
|
|
6
|
+
getBranch: () => LineageEntryLike[];
|
|
7
|
+
getEntries?: () => LineageEntryLike[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const getActiveLineageEntryIds = (sessionManager: LineageSessionManagerLike): Set<string> => {
|
|
11
|
+
try {
|
|
12
|
+
const branch = sessionManager.getBranch() ?? [];
|
|
13
|
+
if (branch.length > 0) {
|
|
14
|
+
return new Set(branch.map((e) => e.id).filter((id): id is string => Boolean(id)));
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
// fall through to defensive fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const all = sessionManager.getEntries?.() ?? [];
|
|
22
|
+
return new Set(all.map((e) => e.id).filter((id): id is string => Boolean(id)));
|
|
23
|
+
} catch {
|
|
24
|
+
return new Set();
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
3
|
+
import { renderMessage, type RenderedEntry } from "./render-entries";
|
|
4
|
+
|
|
5
|
+
interface LoadedMessages {
|
|
6
|
+
rendered: RenderedEntry[];
|
|
7
|
+
rawMessages: Message[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const loadAllMessages = (
|
|
11
|
+
sessionFile: string,
|
|
12
|
+
full: boolean,
|
|
13
|
+
allowedEntryIds?: Set<string>,
|
|
14
|
+
/** Optional filter by global message index (for compaction-scoped searches) */
|
|
15
|
+
entryFilter?: (globalIndex: number) => boolean,
|
|
16
|
+
): LoadedMessages => {
|
|
17
|
+
const content = readFileSync(sessionFile, "utf-8");
|
|
18
|
+
const entries: any[] = [];
|
|
19
|
+
for (const line of content.split("\n")) {
|
|
20
|
+
if (!line.trim()) continue;
|
|
21
|
+
try { entries.push(JSON.parse(line)); } catch {}
|
|
22
|
+
}
|
|
23
|
+
const rendered: RenderedEntry[] = [];
|
|
24
|
+
const rawMessages: Message[] = [];
|
|
25
|
+
let messageIndex = 0;
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
const isMessage = e.type === "message" && e.message;
|
|
28
|
+
if (!isMessage) continue;
|
|
29
|
+
|
|
30
|
+
const allowed = (!allowedEntryIds || allowedEntryIds.has(e.id)) &&
|
|
31
|
+
(!entryFilter || entryFilter(messageIndex));
|
|
32
|
+
if (allowed) {
|
|
33
|
+
rendered.push(renderMessage(e.message, messageIndex, full));
|
|
34
|
+
rawMessages.push(e.message);
|
|
35
|
+
}
|
|
36
|
+
messageIndex++;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { rendered, rawMessages };
|
|
40
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { NormalizedBlock } from "../types";
|
|
3
|
+
import { textOf } from "./content";
|
|
4
|
+
import { sanitize } from "./sanitize";
|
|
5
|
+
|
|
6
|
+
const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
7
|
+
if (msg.role === "user") {
|
|
8
|
+
const blocks: NormalizedBlock[] = [];
|
|
9
|
+
const text = sanitize(textOf(msg.content));
|
|
10
|
+
if (text) blocks.push({ kind: "user", text, sourceIndex: msgIndex });
|
|
11
|
+
if (msg.content && typeof msg.content !== "string") {
|
|
12
|
+
for (const part of msg.content) {
|
|
13
|
+
if (part.type === "image") {
|
|
14
|
+
blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex: msgIndex });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if ((msg as any).role === "bashExecution") {
|
|
22
|
+
const cmd = (msg as any).command ?? "";
|
|
23
|
+
const out = (msg as any).output ?? "";
|
|
24
|
+
const exit = (msg as any).exitCode;
|
|
25
|
+
return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex: msgIndex }];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (msg.role === "toolResult") {
|
|
29
|
+
return [{
|
|
30
|
+
kind: "tool_result",
|
|
31
|
+
name: msg.toolName,
|
|
32
|
+
text: sanitize(textOf(msg.content)),
|
|
33
|
+
isError: msg.isError,
|
|
34
|
+
sourceIndex: msgIndex,
|
|
35
|
+
}];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (msg.role === "assistant") {
|
|
39
|
+
if (!msg.content) return [];
|
|
40
|
+
if (typeof msg.content === "string") {
|
|
41
|
+
return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex: msgIndex }];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const blocks: NormalizedBlock[] = [];
|
|
45
|
+
for (const part of msg.content) {
|
|
46
|
+
if (part.type === "text") {
|
|
47
|
+
blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex: msgIndex });
|
|
48
|
+
} else if (part.type === "thinking") {
|
|
49
|
+
blocks.push({
|
|
50
|
+
kind: "thinking",
|
|
51
|
+
text: sanitize(part.thinking),
|
|
52
|
+
redacted: part.redacted ?? false,
|
|
53
|
+
sourceIndex: msgIndex,
|
|
54
|
+
});
|
|
55
|
+
} else if (part.type === "toolCall") {
|
|
56
|
+
blocks.push({
|
|
57
|
+
kind: "tool_call",
|
|
58
|
+
name: part.name,
|
|
59
|
+
args: part.arguments,
|
|
60
|
+
sourceIndex: msgIndex,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return blocks;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const normalize = (messages: Message[]): NormalizedBlock[] =>
|
|
71
|
+
messages.flatMap((msg, i) => normalizeOne(msg, i));
|
|
72
|
+
|
|
73
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
type RecallScope = "lineage" | "all" | `compaction:${string}`;
|
|
2
|
+
|
|
3
|
+
interface ParsedRecallScope {
|
|
4
|
+
scope: RecallScope;
|
|
5
|
+
compactionIndex?: number; // 0-based compaction number to scope to
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SCOPE_RE = /\bscope:(lineage|all|compaction:(\d+|latest))\b/i;
|
|
10
|
+
|
|
11
|
+
export const normalizeRecallScope = (
|
|
12
|
+
scope?: unknown,
|
|
13
|
+
): RecallScope => {
|
|
14
|
+
if (typeof scope !== "string") return "lineage";
|
|
15
|
+
const lower = scope.toLowerCase();
|
|
16
|
+
if (lower === "all") return "all";
|
|
17
|
+
if (lower.startsWith("compaction:")) return lower as RecallScope;
|
|
18
|
+
return "lineage";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse scope directive from a recall query string or structured scope.
|
|
23
|
+
* Returns the parsed scope and the cleaned query text.
|
|
24
|
+
*/
|
|
25
|
+
export const parseRecallScope = (text: string): ParsedRecallScope => {
|
|
26
|
+
const match = text.match(SCOPE_RE);
|
|
27
|
+
if (!match) {
|
|
28
|
+
return { scope: "lineage", text: text.replace(/\s+/g, " ").trim() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const rawScope = match[0];
|
|
32
|
+
const cleaned = text.replace(SCOPE_RE, "").replace(/\s+/g, " ").trim();
|
|
33
|
+
|
|
34
|
+
// compaction:N or compaction:latest
|
|
35
|
+
if (match[1]?.toLowerCase().startsWith("compaction")) {
|
|
36
|
+
const val = match[2];
|
|
37
|
+
if (val?.toLowerCase() === "latest") {
|
|
38
|
+
return { scope: "compaction:latest", text: cleaned };
|
|
39
|
+
}
|
|
40
|
+
const num = parseInt(val ?? "0", 10);
|
|
41
|
+
return {
|
|
42
|
+
scope: `compaction:${num}` as RecallScope,
|
|
43
|
+
compactionIndex: isNaN(num) ? undefined : num,
|
|
44
|
+
text: cleaned,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { scope: normalizeRecallScope(match[1]), text: cleaned };
|
|
49
|
+
};
|