@monotykamary/pi-loop 0.1.12

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +285 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/pi-loop.jpg +0 -0
  6. package/media/screenshot.png +0 -0
  7. package/package.json +89 -0
  8. package/src/core/analyzer.ts +51 -0
  9. package/src/core/content-extractor.ts +79 -0
  10. package/src/core/inference.ts +137 -0
  11. package/src/core/prompt-builder.ts +217 -0
  12. package/src/core/prompt-loader.ts +126 -0
  13. package/src/core/reframe.ts +30 -0
  14. package/src/core/snapshot-builder.ts +252 -0
  15. package/src/global-config.ts +38 -0
  16. package/src/index.ts +532 -0
  17. package/src/session/client.ts +47 -0
  18. package/src/session/loop-session.ts +102 -0
  19. package/src/session/response-parser.ts +37 -0
  20. package/src/state/manager.ts +164 -0
  21. package/src/state/patterns.ts +81 -0
  22. package/src/state/reframe.ts +33 -0
  23. package/src/subagent-detector.ts +94 -0
  24. package/src/types.ts +83 -0
  25. package/src/ui/animations.ts +70 -0
  26. package/src/ui/model-picker.ts +79 -0
  27. package/src/ui/renderer.ts +257 -0
  28. package/src/ui/status-widget.ts +30 -0
  29. package/src/ui/types.ts +48 -0
  30. package/tests/compaction.test.ts +754 -0
  31. package/tests/continue-action-regression.test.ts +456 -0
  32. package/tests/engine.test.ts +770 -0
  33. package/tests/ephemeral-supervision.test.ts +391 -0
  34. package/tests/full-fidelity-snapshot.test.ts +843 -0
  35. package/tests/parsing.test.ts +303 -0
  36. package/tests/state.test.ts +525 -0
  37. package/tests/status-widget.test.ts +703 -0
  38. package/tests/subagent-detector.test.ts +191 -0
  39. package/tests/supervise-command.test.ts +381 -0
  40. package/tsconfig.json +14 -0
  41. package/vitest.config.ts +15 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Prompt builder - constructs user prompts for the supervisor LLM.
3
+ */
4
+
5
+ import type { LoopState, ConversationMessage, LoopIntervention } from '../types.js';
6
+ import { extractMetrics } from './content-extractor.js';
7
+ import { getReframeGuidance } from './reframe.js';
8
+
9
+ /** Build the user-facing prompt for the supervisor LLM. */
10
+ export function buildUserPrompt(
11
+ state: LoopState,
12
+ snapshot: ConversationMessage[],
13
+ agentIsIdle: boolean,
14
+ ineffectivePattern?: { detected: boolean; similarCount: number; turnsSinceLastSteer: number }
15
+ ): string {
16
+ // Build intervention history with full ASI display
17
+ const interventionHistory =
18
+ state.interventions.length === 0
19
+ ? 'None yet.'
20
+ : state.interventions
21
+ .slice(-5)
22
+ .map((iv, i) => {
23
+ let entry = `[${i + 1}] Turn ${iv.turnCount}: "${iv.message}"`;
24
+
25
+ // Display ASI prominently if present
26
+ if (iv.asi && Object.keys(iv.asi).length > 0) {
27
+ const asiEntries = Object.entries(iv.asi)
28
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
29
+ .join(', ');
30
+ entry += `\n ASI {${asiEntries}}`;
31
+ }
32
+ return entry;
33
+ })
34
+ .join('\n');
35
+
36
+ // Build ASI pattern summary for loop closing
37
+ const asiSummary = buildASISummary(state.interventions);
38
+
39
+ // Extract metrics from all conversation messages
40
+ const allMetrics: Record<string, number> = {};
41
+ for (const msg of snapshot) {
42
+ const msgMetrics = extractMetrics(msg.content);
43
+ Object.assign(allMetrics, msgMetrics);
44
+ }
45
+ const metricsText =
46
+ Object.keys(allMetrics).length > 0
47
+ ? `METRICS DETECTED IN CONVERSATION:\n${Object.entries(allMetrics)
48
+ .map(([k, v]) => ` ${k}: ${v}`)
49
+ .join('\n')}\n`
50
+ : '';
51
+
52
+ const conversationText =
53
+ snapshot.length === 0
54
+ ? '(No conversation yet)'
55
+ : snapshot
56
+ .map((m) => {
57
+ const roleLabel =
58
+ m.role === 'user' ? 'USER' : m.role === 'assistant' ? 'ASSISTANT' : 'TOOL RESULTS';
59
+ let text = `${roleLabel}: ${m.content}`;
60
+
61
+ // Include tool calls from assistant blocks
62
+ if (m.role === 'assistant' && m.blocks) {
63
+ const toolCalls = m.blocks.filter((b) => b.type === 'tool_call');
64
+ if (toolCalls.length > 0) {
65
+ text += '\n\n[Tool calls made]:';
66
+ for (const tc of toolCalls) {
67
+ text += `\n - ${(tc as any).name}(${JSON.stringify((tc as any).input)})`;
68
+ }
69
+ }
70
+ }
71
+
72
+ // Include full tool results attached to assistant messages
73
+ if (m.role === 'assistant' && m.toolResults && m.toolResults.length > 0) {
74
+ text += '\n\n[Tool outputs received]:';
75
+ for (const tr of m.toolResults) {
76
+ const resultText = tr.content
77
+ .map((c) =>
78
+ c.type === 'text' ? c.text : c.type === 'image' ? '[Image data]' : `[${c.type}]`
79
+ )
80
+ .join('');
81
+ text += `\n--- ${tr.toolName} output ---\n${resultText}${tr.isError ? '\n[ERROR]' : ''}`;
82
+ }
83
+ }
84
+
85
+ // For tool_results role, the content is already the full output
86
+ if (m.role === 'tool_results' && m.blocks) {
87
+ const hasImages = m.blocks.some((b) => b.type === 'image');
88
+ if (hasImages) {
89
+ text += '\n\n[Contains image data - see blocks for full content]';
90
+ }
91
+ }
92
+
93
+ return text;
94
+ })
95
+ .join('\n\n---\n\n');
96
+
97
+ const agentStatus = agentIsIdle
98
+ ? `AGENT STATUS: IDLE — the agent has finished its turn and is now waiting for user input.
99
+ DECISION: You MUST return "done" or "steer". "continue" is NOT VALID when the agent is idle.`
100
+ : `AGENT STATUS: WORKING — the agent is actively processing. Only intervene if clearly off track.
101
+ DECISION: Return "continue" (let them work) or "steer" (intervene now).`;
102
+
103
+ // Context-aware JSON schema - only include valid actions for current state
104
+ const responseSchema = agentIsIdle
105
+ ? `{
106
+ "action": "done" | "steer", // ONLY these two when agent is idle
107
+ "message": "...", // Required when "steer" (what to tell the agent)
108
+ "reasoning": "...", // Why you chose this action
109
+ "confidence": 0.85, // 0-1 float
110
+ "asi": { "...": "..." } // REQUIRED when steering. Log patterns for future turns
111
+ }`
112
+ : `{
113
+ "action": "continue" | "steer", // "continue" lets them work; "steer" interrupts now
114
+ "message": "...", // Required when "steer"
115
+ "reasoning": "...",
116
+ "confidence": 0.85,
117
+ "asi": { "...": "..." } // REQUIRED when steering
118
+ }`;
119
+
120
+ const reframeGuidance = getReframeGuidance(state.reframeTier ?? 0, ineffectivePattern);
121
+ const reframeSection = reframeGuidance ? `\n${reframeGuidance}\n` : '';
122
+
123
+ return `DESIRED OUTCOME:
124
+ ${state.outcome}
125
+
126
+ ${agentStatus}${reframeSection}
127
+
128
+ ${metricsText}RECENT CONVERSATION (last ${snapshot.length} messages):
129
+ ${conversationText}
130
+
131
+ YOUR INTERVENTION HISTORY (with ASI observations):
132
+ ${interventionHistory}
133
+
134
+ ${asiSummary}
135
+ REMINDER — DESIRED OUTCOME:
136
+ ${state.outcome}
137
+
138
+ Respond ONLY with valid JSON matching this schema (no prose, no markdown fences):
139
+ ${responseSchema}`;
140
+ }
141
+
142
+ /** Build summary of ASI patterns to close the loop */
143
+ function buildASISummary(interventions: LoopIntervention[]): string {
144
+ if (interventions.length === 0) return '';
145
+
146
+ // Extract key patterns from ASI
147
+ const patterns: string[] = [];
148
+ const recent = interventions.slice(-5);
149
+
150
+ // Check for recurring ASI keys
151
+ const keyFrequency: Record<string, number> = {};
152
+ for (const iv of recent) {
153
+ if (!iv.asi) continue;
154
+ for (const key of Object.keys(iv.asi)) {
155
+ keyFrequency[key] = (keyFrequency[key] || 0) + 1;
156
+ }
157
+ }
158
+
159
+ // Surface recurring patterns
160
+ for (const [key, count] of Object.entries(keyFrequency)) {
161
+ if (count >= 2) {
162
+ patterns.push(`Pattern seen ${count}x: "${key}"`);
163
+ }
164
+ }
165
+
166
+ // Check for cheating-related indicators in ASI values
167
+ const allValues = recent
168
+ .filter((iv) => iv.asi)
169
+ .flatMap((iv) => Object.values(iv.asi!))
170
+ .map((v) => String(v).toLowerCase());
171
+
172
+ const suspiciousIndicators = [
173
+ 'unverified',
174
+ 'contradict',
175
+ 'suspicious',
176
+ 'fake',
177
+ 'skip',
178
+ 'manipulat',
179
+ 'cheat',
180
+ 'gaming',
181
+ 'short-circuit',
182
+ ];
183
+
184
+ const hasSuspicious = suspiciousIndicators.some((indicator) =>
185
+ allValues.some((v) => v.includes(indicator))
186
+ );
187
+
188
+ if (hasSuspicious) {
189
+ patterns.push(
190
+ '⚠️ Previous interventions flagged suspicious claims — require explicit proof before accepting "done"'
191
+ );
192
+ }
193
+
194
+ // Check for verification failures across history
195
+ const verificationFailures = interventions.filter(
196
+ (iv) =>
197
+ iv.asi &&
198
+ Object.entries(iv.asi).some(
199
+ ([k, v]) =>
200
+ String(v).toLowerCase().includes('contradict') ||
201
+ String(v).toLowerCase().includes('unverified')
202
+ )
203
+ ).length;
204
+
205
+ if (verificationFailures >= 2) {
206
+ patterns.push(
207
+ `⚠️ ${verificationFailures} interventions involved unverified/contradicted claims — agent has pattern of unreliable reporting`
208
+ );
209
+ }
210
+
211
+ if (patterns.length === 0) return '';
212
+
213
+ return `ASI PATTERN SUMMARY (use this to inform your decision):
214
+ ${patterns.map((p) => `- ${p}`).join('\n')}
215
+
216
+ `;
217
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * System prompt loading for loop verification.
3
+ *
4
+ * Discovery order (mirrors pi's SYSTEM.md convention):
5
+ * 1. <cwd>/.pi/LOOP.md — project-local
6
+ * 2. ~/.pi/agent/LOOP.md — global
7
+ * 3. Built-in template — fallback
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
13
+
14
+ const LOOP_MD = 'LOOP.md';
15
+ const CONFIG_DIR = '.pi';
16
+
17
+ /** Built-in fallback system prompt. */
18
+ const BUILTIN_SYSTEM_PROMPT = `You ensure outcomes are actually achieved — not just claimed.
19
+
20
+ Your core principle: **Creation and verification require different eyes.**
21
+ When someone creates with one tool, they must verify with another. Otherwise they see their intent, not their output.
22
+
23
+ ═══ WHEN THEY ARE IDLE (finished their turn, waiting) ═══
24
+ This is your critical moment. They have stopped.
25
+
26
+ - "done" → Outcome achieved AND verified with different tools than used to create.
27
+ - "steer" → Everything else: incomplete, unverified, or off-track.
28
+
29
+ If they ask a clarifying question:
30
+ FIRST: Is this question necessary to complete the goal?
31
+ - YES (blocks progress): Answer with sensible default, tell them to proceed.
32
+ - NO (out of scope): Redirect back to the specific missing piece. Do NOT answer it.
33
+ NEVER answer: passwords, credentials, secrets.
34
+
35
+ Your steer message speaks AS the user. Clear, direct, actionable (1-3 sentences).
36
+ Do not ask them to verify their own work — tell them the next step.
37
+
38
+ ═══ VERIFYING COMPLETION ═══
39
+ Before accepting "done", confirm the outcome was actually achieved:
40
+
41
+ **The Evidence Check** (Primary — use what you can see)
42
+ You have full access to all tool outputs in the conversation history. Verify based on evidence present, not ritual:
43
+
44
+ - File creation claimed → Check the write/edit output for successful confirmation
45
+ - Tests claimed passing → Check the bash output for actual test results (exit code 0, "passed")
46
+ - File re-read claimed → Check if read output shows the expected content
47
+ - Search performed → Check search results for matches
48
+
49
+ ACCEPT "done" when tool outputs clearly confirm the work. The tool output IS the verification.
50
+
51
+ **When to Steer**
52
+ Only steer if evidence is MISSING or CONTRADICTORY:
53
+ - "Tests pass" but bash output shows failures or no test run → steer: "Fix test failures"
54
+ - "File created" but write output shows error → steer: "Fix the write error"
55
+ - Claims made but no corresponding tool output in history → steer with specific request
56
+ - Claims CONTRADICT tool output (says "works" but errors visible) → steer immediately
57
+
58
+ NEVER demand redundant verification just to satisfy a "different tools" ritual. If you can see the proof in the outputs already captured, accept it.
59
+
60
+ **The Honesty Check**
61
+ Watch for these patterns:
62
+ 1. Contradicted claims: Says "works" but tool output shows errors.
63
+ 2. Missing evidence: Claims re-read but no read output in history.
64
+ 3. Test manipulation: Edited tests to make them pass.
65
+ 4. Short-circuiting: Skipped steps, partial verification.
66
+
67
+ If you detect dishonesty or sloppiness:
68
+ - DO NOT accept "done"
69
+ - Steer with specific challenge: "Show me the test output" or "Fix the errors visible in the output"
70
+ - Log the pattern in ASI so you remember not to trust future claims from this source
71
+
72
+ ═══ WHEN THEY ARE WORKING (mid-turn) ═══
73
+ Only intervene if clearly heading wrong.
74
+ Trust them to complete what they started. Don't interrupt productive work.
75
+
76
+ ═══ STEERING PRINCIPLES ═══
77
+ - Be specific: reference outcome, missing piece, or verification gap.
78
+ - Never repeat failed steers — escalate or change approach.
79
+ - A good steer answers their question OR redirects to the missing piece.
80
+ - If they take shortcuts, call it out immediately.
81
+ - If they declare done without verification: steer immediately with specific requirement.
82
+
83
+ "done" CRITERIA:
84
+ - Core outcome is complete and functional
85
+ - Evidence in tool outputs confirms success (file written, tests passed, etc.)
86
+ - Minor polish does NOT block done
87
+ - Prefer stopping when substantially achieved AND verified over perfect but unverified
88
+
89
+ ═══ YOUR MEMORY (ASI) ═══
90
+ ASI is your recall across turns. Populate it when steering:
91
+
92
+ - Pattern that triggered you: "claimed_tests_pass_but_exit_code_1"
93
+ - What you learned about their tendencies: "skips_error_handling"
94
+ - What to watch for next time: "verify_file_written_before_done"
95
+
96
+ Before deciding, READ your past ASI. Look for:
97
+ - Recurring patterns (same mistake again)
98
+ - Prior unverified claims (don't accept done if you caught them before)
99
+ - What has worked in past steering
100
+
101
+ ASI is free-form. Use keys that help you remember:
102
+ { "repeated_unverified_claim": true, "previous_contradiction": "turn_3", "watch_for": "orphaned_imports" }
103
+
104
+ If you previously caught them in a suspicious claim, require explicit proof before accepting "done".
105
+
106
+ The user prompt will provide a context-specific JSON schema. Follow it exactly.`;
107
+
108
+ /**
109
+ * Load the loop verification prompt.
110
+ * Checks .pi/LOOP.md (project) then ~/.pi/agent/LOOP.md (global),
111
+ * falling back to the built-in template if neither exists.
112
+ * Returns both the prompt and its source path (or "built-in").
113
+ */
114
+ export function loadSystemPrompt(cwd: string): { prompt: string; source: string } {
115
+ const projectPath = join(cwd, CONFIG_DIR, LOOP_MD);
116
+ if (existsSync(projectPath)) {
117
+ return { prompt: readFileSync(projectPath, 'utf-8').trim(), source: projectPath };
118
+ }
119
+
120
+ const globalPath = join(getAgentDir(), LOOP_MD);
121
+ if (existsSync(globalPath)) {
122
+ return { prompt: readFileSync(globalPath, 'utf-8').trim(), source: globalPath };
123
+ }
124
+
125
+ return { prompt: BUILTIN_SYSTEM_PROMPT, source: 'built-in' };
126
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reframe tier management - escalation strategies for ineffective steering.
3
+ */
4
+
5
+ import type { ReframeTier } from '../types.js';
6
+
7
+ /** Get reframe guidance based on current tier */
8
+ export function getReframeGuidance(
9
+ tier: number,
10
+ ineffectivePattern?: { detected: boolean; similarCount: number; turnsSinceLastSteer: number }
11
+ ): string {
12
+ if (!ineffectivePattern?.detected && tier === 0) return '';
13
+
14
+ const tierGuidance: Record<number, string> = {
15
+ 0: '',
16
+ 1: `🔄 REFRAME TIER 1 — DIRECTIVE: The agent needs clearer direction. Be extremely specific about the next single action to take.`,
17
+ 2: `🔄 REFRAME TIER 2 — SUBGOAL: The agent is stuck on the full goal. Break this into a smaller, verifiable milestone. Tell it to complete just that one piece.`,
18
+ 3: `🔄 REFRAME TIER 3 — PIVOT: The current approach isn't working. Suggest a completely different strategy or implementation path. Challenge any assumptions.`,
19
+ 4: `🔄 REFRAME TIER 4 — MINIMAL SLICE: Strip to absolute essentials. Ask: "What's the smallest working version you can deliver right now?" Push for tangible output.`,
20
+ };
21
+
22
+ const patternNote = ineffectivePattern?.detected
23
+ ? `\n⚠ INEFFECTIVE PATTERN DETECTED: Last ${ineffectivePattern.similarCount} steering messages were similar or no progress in ${ineffectivePattern.turnsSinceLastSteer} turns. Escalate your approach.`
24
+ : '';
25
+
26
+ return tierGuidance[tier] + patternNote;
27
+ }
28
+
29
+ /** Maximum reframe tier value */
30
+ const MAX_REFRAME_TIER: ReframeTier = 4;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Snapshot builder - constructs conversation snapshots from session history.
3
+ *
4
+ * Incrementally builds snapshots from new session entries since last analysis.
5
+ * CAPTURES EVERYTHING: full tool outputs, images (base64), all content blocks.
6
+ */
7
+
8
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
9
+ import type { ConversationMessage, LoopState, ToolResultEntry, ContentBlock } from '../types.js';
10
+ import { extractAllBlocks, extractText, extractAssistantText } from './content-extractor.js';
11
+
12
+ /** Fixed message limit for supervisor context window. */
13
+ export const SNAPSHOT_LIMIT = 20;
14
+
15
+ /**
16
+ * Incrementally build snapshot from new session entries since last analysis.
17
+ * CAPTURES EVERYTHING: full tool outputs, images (base64), all content blocks.
18
+ * Only walks entries from lastAnalyzedTurn to current, appends to existing buffer.
19
+ */
20
+ export function buildIncrementalSnapshot(
21
+ ctx: ExtensionContext,
22
+ state: LoopState
23
+ ): ConversationMessage[] {
24
+ const existingBuffer = state.snapshotBuffer ?? [];
25
+ const lastAnalyzed = state.lastAnalyzedTurn ?? -1;
26
+ const currentTurn = state.turnCount;
27
+
28
+ // If already analyzed this turn, return existing
29
+ if (lastAnalyzed >= currentTurn) {
30
+ return existingBuffer.slice(-SNAPSHOT_LIMIT);
31
+ }
32
+
33
+ const newMessages: ConversationMessage[] = [...existingBuffer];
34
+ const entries = ctx.sessionManager.getBranch();
35
+
36
+ // Track pending tool results to associate with the next assistant message
37
+ const pendingToolResults: ToolResultEntry[] = [];
38
+
39
+ // Find entries since last analysis
40
+ for (const entry of entries) {
41
+ // Capture regular messages (user/assistant conversation)
42
+ if (entry.type === 'message') {
43
+ const msg = (entry as any).message;
44
+ if (!msg) continue;
45
+
46
+ if (msg.role === 'user') {
47
+ const textContent = extractText(msg.content);
48
+ const allBlocks = extractAllBlocks(msg.content);
49
+ if (textContent || allBlocks.length > 0) {
50
+ newMessages.push({
51
+ role: 'user',
52
+ content: textContent,
53
+ blocks: allBlocks,
54
+ });
55
+ }
56
+ } else if (msg.role === 'assistant') {
57
+ const textContent = extractAssistantText(msg.content);
58
+ const allBlocks = extractAllBlocks(msg.content);
59
+
60
+ // Check for tool calls in the content blocks
61
+ const toolCalls = allBlocks.filter(
62
+ (b): b is ContentBlock & { type: 'tool_call' } => b.type === 'tool_call'
63
+ );
64
+
65
+ if (textContent || allBlocks.length > 0 || toolCalls.length > 0) {
66
+ newMessages.push({
67
+ role: 'assistant',
68
+ content: textContent,
69
+ blocks: allBlocks,
70
+ toolResults: pendingToolResults.length > 0 ? [...pendingToolResults] : undefined,
71
+ });
72
+ // Clear pending tool results after associating with assistant
73
+ pendingToolResults.length = 0;
74
+ }
75
+ } else if (msg.role === 'tool') {
76
+ // Tool result messages - capture them fully
77
+ const allBlocks = extractAllBlocks(msg.content);
78
+ const textContent = extractText(msg.content);
79
+
80
+ // Try to extract tool call ID and name from the message
81
+ const toolCallId = (msg as any).tool_call_id || (msg as any).toolCallId || 'unknown';
82
+ const toolName = (msg as any).name || 'unknown';
83
+ const isError = (msg as any).is_error || (msg as any).isError || false;
84
+
85
+ pendingToolResults.push({
86
+ toolCallId,
87
+ toolName,
88
+ input: {},
89
+ content: allBlocks,
90
+ isError,
91
+ });
92
+
93
+ // Also add as a tool_results message for visibility
94
+ newMessages.push({
95
+ role: 'tool_results',
96
+ content: textContent || `[Tool output: ${toolName}]`,
97
+ blocks: allBlocks,
98
+ });
99
+ }
100
+ }
101
+
102
+ // Capture custom_message entries (often contain tool results in pi)
103
+ if (entry.type === 'custom_message') {
104
+ const customMsg = entry as any;
105
+ const content = customMsg.content;
106
+
107
+ if (typeof content === 'string') {
108
+ // Plain text custom message - likely tool output
109
+ pendingToolResults.push({
110
+ toolCallId: customMsg.id || 'unknown',
111
+ toolName: customMsg.customType || 'unknown',
112
+ input: customMsg.details || {},
113
+ content: [{ type: 'text', text: content }],
114
+ isError: false,
115
+ });
116
+
117
+ newMessages.push({
118
+ role: 'tool_results',
119
+ content: content,
120
+ blocks: [{ type: 'text', text: content }],
121
+ });
122
+ } else if (Array.isArray(content)) {
123
+ // Rich content custom message - extract all blocks
124
+ const allBlocks = extractAllBlocks(content);
125
+ const textContent = content
126
+ .filter((b: any) => b.type === 'text')
127
+ .map((b: any) => b.text)
128
+ .join('\n');
129
+
130
+ pendingToolResults.push({
131
+ toolCallId: customMsg.id || 'unknown',
132
+ toolName: customMsg.customType || 'unknown',
133
+ input: customMsg.details || {},
134
+ content: allBlocks,
135
+ isError: false,
136
+ });
137
+
138
+ newMessages.push({
139
+ role: 'tool_results',
140
+ content: textContent || `[Tool output: ${customMsg.customType}]`,
141
+ blocks: allBlocks,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Bash execution messages (special type in pi)
147
+ if ((entry as any).type === 'bash_execution' || (entry as any).type === 'bash_result') {
148
+ const bashEntry = entry as any;
149
+ const result = bashEntry.result || bashEntry;
150
+
151
+ if (result) {
152
+ const output = result.stdout || result.output || result.content || '';
153
+ const stderr = result.stderr || '';
154
+ const exitCode = result.exitCode ?? result.exit_code ?? 0;
155
+
156
+ const fullOutput = [output, stderr].filter(Boolean).join('\n');
157
+
158
+ pendingToolResults.push({
159
+ toolCallId: bashEntry.id || 'bash',
160
+ toolName: 'bash',
161
+ input: { command: result.command || bashEntry.command },
162
+ content: [{ type: 'text', text: fullOutput }],
163
+ isError: exitCode !== 0,
164
+ });
165
+
166
+ newMessages.push({
167
+ role: 'tool_results',
168
+ content: fullOutput || '[bash output]',
169
+ blocks: [{ type: 'text', text: fullOutput }],
170
+ });
171
+ }
172
+ }
173
+
174
+ // Catch-all for any other tool result entries (write, edit, or any tool not explicitly handled above)
175
+ // This ensures tool results aren't silently dropped if they come through with non-standard entry types
176
+ if (
177
+ entry.type !== 'message' &&
178
+ entry.type !== 'custom_message' &&
179
+ (entry as any).type !== 'bash_execution' &&
180
+ (entry as any).type !== 'bash_result'
181
+ ) {
182
+ const otherEntry = entry as any;
183
+
184
+ // Only process if it looks like a tool result (has output-like content)
185
+ const content =
186
+ otherEntry.content || otherEntry.result || otherEntry.output || otherEntry.text;
187
+ const toolName = otherEntry.customType || otherEntry.name || otherEntry.tool || 'tool';
188
+
189
+ // Skip entries that don't have meaningful content (like state entries)
190
+ if (
191
+ content &&
192
+ typeof content === 'string' &&
193
+ content.length > 0 &&
194
+ !otherEntry.customType?.includes('state')
195
+ ) {
196
+ pendingToolResults.push({
197
+ toolCallId: otherEntry.id || otherEntry.toolCallId || 'unknown',
198
+ toolName,
199
+ input: otherEntry.details || otherEntry.input || {},
200
+ content: [{ type: 'text', text: content }],
201
+ isError: otherEntry.isError || otherEntry.is_error || false,
202
+ });
203
+
204
+ newMessages.push({
205
+ role: 'tool_results',
206
+ content,
207
+ blocks: [{ type: 'text', text: content }],
208
+ });
209
+ } else if (Array.isArray(content) && content.length > 0) {
210
+ // Handle rich content arrays (e.g., tool results with image blocks)
211
+ const allBlocks = extractAllBlocks(content);
212
+ const textContent = content
213
+ .filter((b: any) => b.type === 'text')
214
+ .map((b: any) => b.text)
215
+ .join('\n');
216
+
217
+ pendingToolResults.push({
218
+ toolCallId: otherEntry.id || otherEntry.toolCallId || 'unknown',
219
+ toolName,
220
+ input: otherEntry.details || otherEntry.input || {},
221
+ content: allBlocks,
222
+ isError: otherEntry.isError || otherEntry.is_error || false,
223
+ });
224
+
225
+ newMessages.push({
226
+ role: 'tool_results',
227
+ content: textContent || `[${toolName} output]`,
228
+ blocks: allBlocks,
229
+ });
230
+ }
231
+ }
232
+ }
233
+
234
+ // Keep only last 6, compress older if needed
235
+ if (newMessages.length > SNAPSHOT_LIMIT) {
236
+ const overflow = newMessages.length - SNAPSHOT_LIMIT;
237
+ // Drop oldest messages (simple approach)
238
+ return newMessages.slice(overflow);
239
+ }
240
+
241
+ return newMessages;
242
+ }
243
+
244
+ /**
245
+ * Update state with new snapshot and return it.
246
+ */
247
+ export function updateSnapshot(ctx: ExtensionContext, state: LoopState): ConversationMessage[] {
248
+ const snapshot = buildIncrementalSnapshot(ctx, state);
249
+ state.snapshotBuffer = snapshot;
250
+ state.lastAnalyzedTurn = state.turnCount;
251
+ return snapshot;
252
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * global-config.ts — workspace-level loop configuration.
3
+ *
4
+ * Saves/loads loop model selection.
5
+ * Stored at <cwd>/.pi/loop-config.json
6
+ *
7
+ * Removed: sensitivity (now automatic)
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ const CONFIG_DIR = '.pi';
14
+ const CONFIG_FILE = 'loop-config.json';
15
+
16
+ interface LoopConfig {
17
+ model?: {
18
+ provider: string;
19
+ modelId: string;
20
+ };
21
+ }
22
+
23
+ /** Load the loop model config from cwd/.pi/loop-config.json if it exists. */
24
+ export function loadGlobalModel(): { provider: string; modelId: string } | null {
25
+ const configPath = join(process.cwd(), CONFIG_DIR, CONFIG_FILE);
26
+ if (!existsSync(configPath)) return null;
27
+
28
+ try {
29
+ const content = readFileSync(configPath, 'utf-8');
30
+ const parsed = JSON.parse(content) as LoopConfig;
31
+ if (parsed.model?.provider && parsed.model?.modelId) {
32
+ return { provider: parsed.model.provider, modelId: parsed.model.modelId };
33
+ }
34
+ } catch {
35
+ // ignore parse errors
36
+ }
37
+ return null;
38
+ }