@aexol/spectral 0.6.0 → 0.6.2

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.
@@ -38,7 +38,7 @@ import { fileURLToPath } from "node:url";
38
38
  import { getConfigDir } from "../config.js";
39
39
  import { requireLogin } from "../preflight.js";
40
40
  import { RelayClient } from "../relay/client.js";
41
- import { detachAllSubscribers, handleCancelTurn, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
41
+ import { detachAllSubscribers, handleAutoResearchFrame, handleCancelTurn, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
42
42
  import { ensureMachineRegistered } from "../relay/registration.js";
43
43
  import { SessionStreamManager } from "../server/session-stream.js";
44
44
  import { gracefulShutdown } from "../server/shutdown.js";
@@ -260,6 +260,15 @@ export async function runServe(opts = {}) {
260
260
  handleCancelTurn(frame, { manager });
261
261
  return;
262
262
  }
263
+ if (frame.kind === "auto_research") {
264
+ handleAutoResearchFrame(frame, {
265
+ store,
266
+ manager,
267
+ relay,
268
+ cwd,
269
+ });
270
+ return;
271
+ }
263
272
  // Other frames (error, machine_disconnected addressed to us, etc.)
264
273
  // are ignored at this layer. Future batches may surface them in
265
274
  // structured logs.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Auto-research handler — spawns an isolated pi subprocess to analyze a
3
+ * project and generate custom extensions.
4
+ *
5
+ * Flow:
6
+ * 1. Resolve project path from the SQLite store
7
+ * 2. Spawn pi with --mode json -p running the auto-research agent
8
+ * 3. Parse JSON-line output from the subprocess
9
+ * 4. Map subprocess events to `auto_research_*` ServerEvent types
10
+ * 5. Stream progress via the relay to the browser
11
+ * 6. On completion, emit `auto_research_complete` with generated extensions
12
+ *
13
+ * The subprocess runs with `cwd` set to the project root so all file
14
+ * reads/writes are relative to the project. The agent definition markdown
15
+ * file lives alongside other agent defs and gets loaded by pi's subagent
16
+ * infrastructure.
17
+ */
18
+ import { spawn } from "node:child_process";
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Detect the pi/spectral binary for spawning subprocesses.
26
+ * Mirrors the logic in `agent/index.ts#getPiInvocation`.
27
+ */
28
+ function getPiInvocation(subagentArgs) {
29
+ const currentScript = process.argv[1];
30
+ // Case 1: running via tsx with an existing script file
31
+ if (currentScript && fs.existsSync(currentScript)) {
32
+ return { command: process.execPath, args: [currentScript, ...subagentArgs] };
33
+ }
34
+ // Case 2: check if this is the spectral/aexol wrapper binary
35
+ const execName = path.basename(process.execPath).toLowerCase();
36
+ if (execName === "spectral" || execName === "aexol") {
37
+ return { command: process.execPath, args: subagentArgs };
38
+ }
39
+ // Case 3: generic node — try spectral first, then pi
40
+ function hasBin(name) {
41
+ const PATH = process.env.PATH || "";
42
+ const envPathSep = process.platform === "win32" ? ";" : ":";
43
+ for (const dir of PATH.split(envPathSep)) {
44
+ const candidate = path.join(dir, name);
45
+ try {
46
+ if (fs.existsSync(candidate))
47
+ return true;
48
+ }
49
+ catch {
50
+ /* skip */
51
+ }
52
+ }
53
+ return false;
54
+ }
55
+ if (hasBin("spectral")) {
56
+ return { command: "spectral", args: subagentArgs };
57
+ }
58
+ if (hasBin("pi")) {
59
+ return { command: "pi", args: subagentArgs };
60
+ }
61
+ return { command: "pi", args: subagentArgs };
62
+ }
63
+ /**
64
+ * Send a ServerEvent to the browser via the relay on the auto-research session.
65
+ */
66
+ function sendEvent(deps, sessionId, event) {
67
+ deps.relay.send({
68
+ kind: "ws_event",
69
+ sessionId,
70
+ event,
71
+ });
72
+ }
73
+ /**
74
+ * Safely kill a child process. Best-effort — errors are silently swallowed.
75
+ */
76
+ function killProcess(child) {
77
+ try {
78
+ if (!child.killed && child.exitCode === null) {
79
+ child.kill("SIGTERM");
80
+ // Give it 2 seconds to clean up, then force-kill
81
+ setTimeout(() => {
82
+ try {
83
+ if (!child.killed && child.exitCode === null) {
84
+ child.kill("SIGKILL");
85
+ }
86
+ }
87
+ catch {
88
+ // ignore
89
+ }
90
+ }, 2000);
91
+ }
92
+ }
93
+ catch {
94
+ // ignore
95
+ }
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Handler
99
+ // ---------------------------------------------------------------------------
100
+ const AR_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous for LLM-based analysis
101
+ /**
102
+ * Execute auto-research for a project.
103
+ *
104
+ * Caller (dispatcher) is fire-and-forget — this function is `void` and
105
+ * all errors are surfaced as `auto_research_error` events on the wire
106
+ * rather than thrown.
107
+ */
108
+ export function handleAutoResearch(input, deps) {
109
+ const { projectId, sessionId } = input;
110
+ const { store, relay } = deps;
111
+ const logger = deps.logger ?? console;
112
+ // 1. Resolve the project from the store.
113
+ const project = store.getProject(projectId);
114
+ if (!project) {
115
+ sendEvent(deps, sessionId, {
116
+ type: "auto_research_error",
117
+ projectId,
118
+ message: `Project not found: ${projectId}`,
119
+ });
120
+ return;
121
+ }
122
+ const projectPath = project.path;
123
+ // 2. Emit start event
124
+ sendEvent(deps, sessionId, {
125
+ type: "auto_research_start",
126
+ projectId,
127
+ });
128
+ // 3. Build the prompt for the auto-research agent.
129
+ // The prompt instructs the agent to analyze the project and produce
130
+ // JSON-line output describing suggested extensions.
131
+ const task = buildAutoResearchTask(projectPath, project.name);
132
+ // 4. Spawn pi subprocess.
133
+ // We use --mode json for structured output, -p for non-interactive,
134
+ // and pipe stdin/stdout.
135
+ const args = ["--mode", "json", "-p", "--no-session"];
136
+ const invocation = getPiInvocation(args);
137
+ let child;
138
+ try {
139
+ child = spawn(invocation.command, invocation.args, {
140
+ cwd: projectPath,
141
+ stdio: ["pipe", "pipe", "pipe"],
142
+ env: { ...process.env },
143
+ shell: false,
144
+ });
145
+ }
146
+ catch (err) {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ sendEvent(deps, sessionId, {
149
+ type: "auto_research_error",
150
+ projectId,
151
+ message: `Failed to spawn pi subprocess: ${msg}`,
152
+ });
153
+ return;
154
+ }
155
+ // 5. Set up timeout
156
+ const timeout = setTimeout(() => {
157
+ killProcess(child);
158
+ sendEvent(deps, sessionId, {
159
+ type: "auto_research_error",
160
+ projectId,
161
+ message: "Auto-research timed out after 5 minutes",
162
+ });
163
+ }, AR_TIMEOUT_MS);
164
+ // 6. Collect stdout lines and parse as JSON events
165
+ let stdoutBuffer = "";
166
+ const discoveredExtensions = [];
167
+ let stderrBuffer = "";
168
+ child.stdout?.on("data", (chunk) => {
169
+ stdoutBuffer += chunk.toString("utf-8");
170
+ // Process complete lines
171
+ const lines = stdoutBuffer.split("\n");
172
+ // The last element may be an incomplete line — keep it in the buffer
173
+ stdoutBuffer = lines.pop() ?? "";
174
+ for (const rawLine of lines) {
175
+ const line = rawLine.trim();
176
+ if (!line)
177
+ continue;
178
+ let parsed;
179
+ try {
180
+ parsed = JSON.parse(line);
181
+ }
182
+ catch {
183
+ // Non-JSON output — could be a log line. Skip.
184
+ continue;
185
+ }
186
+ processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
187
+ }
188
+ });
189
+ child.stderr?.on("data", (chunk) => {
190
+ stderrBuffer += chunk.toString("utf-8");
191
+ });
192
+ // 7. Handle process exit
193
+ child.on("close", (code) => {
194
+ clearTimeout(timeout);
195
+ // Process any remaining buffered stdout
196
+ if (stdoutBuffer.trim()) {
197
+ try {
198
+ const parsed = JSON.parse(stdoutBuffer.trim());
199
+ processArLine(parsed, discoveredExtensions, projectId, sessionId, deps);
200
+ }
201
+ catch {
202
+ // ignore
203
+ }
204
+ }
205
+ if (code !== 0 && discoveredExtensions.length === 0) {
206
+ // Subprocess exited with error and no extensions were generated
207
+ const errDetail = stderrBuffer
208
+ ? ` (stderr: ${stderrBuffer.slice(0, 500)})`
209
+ : "";
210
+ sendEvent(deps, sessionId, {
211
+ type: "auto_research_error",
212
+ projectId,
213
+ message: `Auto-research subprocess exited with code ${code}${errDetail}`,
214
+ });
215
+ return;
216
+ }
217
+ // Emit completion with any discovered extensions (even partial — the
218
+ // UI can show what was generated before the process died).
219
+ sendEvent(deps, sessionId, {
220
+ type: "auto_research_complete",
221
+ projectId,
222
+ extensions: discoveredExtensions,
223
+ });
224
+ // Log any stderr for debugging
225
+ if (stderrBuffer) {
226
+ logger.error?.(`[auto-research] subprocess stderr (code=${code}): ${stderrBuffer.slice(0, 1000)}`);
227
+ }
228
+ });
229
+ child.on("error", (err) => {
230
+ clearTimeout(timeout);
231
+ sendEvent(deps, sessionId, {
232
+ type: "auto_research_error",
233
+ projectId,
234
+ message: `Auto-research subprocess error: ${err.message}`,
235
+ });
236
+ });
237
+ // 8. Write the task to stdin and close
238
+ child.stdin?.write(task);
239
+ child.stdin?.end();
240
+ }
241
+ // ---------------------------------------------------------------------------
242
+ // Line processor
243
+ // ---------------------------------------------------------------------------
244
+ function processArLine(parsed, extensions, projectId, sessionId, deps) {
245
+ const t = parsed.type;
246
+ if (t === "progress") {
247
+ const p = parsed;
248
+ const wirePhase = mapPhase(p.phase);
249
+ sendEvent(deps, sessionId, {
250
+ type: "auto_research_progress",
251
+ projectId,
252
+ phase: wirePhase,
253
+ message: p.message,
254
+ });
255
+ }
256
+ else if (t === "extension_generated") {
257
+ const eg = parsed;
258
+ extensions.push({
259
+ name: eg.name,
260
+ path: eg.path,
261
+ description: eg.description,
262
+ usesLLM: eg.usesLLM,
263
+ fileCount: eg.fileCount,
264
+ });
265
+ }
266
+ else if (t === "done") {
267
+ const d = parsed;
268
+ for (const ext of d.extensions) {
269
+ extensions.push(ext);
270
+ }
271
+ }
272
+ else if (t === "error") {
273
+ const e = parsed;
274
+ sendEvent(deps, sessionId, {
275
+ type: "auto_research_progress",
276
+ projectId,
277
+ phase: "extension_validating",
278
+ message: `Error: ${e.message}`,
279
+ });
280
+ }
281
+ // Unknown event types are silently ignored for forward compatibility
282
+ }
283
+ // ---------------------------------------------------------------------------
284
+ // Phase mapping
285
+ // ---------------------------------------------------------------------------
286
+ const VALID_PHASES = new Set([
287
+ "context_collecting",
288
+ "context_analyzing",
289
+ "extension_generating",
290
+ "extension_validating",
291
+ ]);
292
+ function mapPhase(agentPhase) {
293
+ if (VALID_PHASES.has(agentPhase)) {
294
+ return agentPhase;
295
+ }
296
+ return "context_analyzing";
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Task builder
300
+ // ---------------------------------------------------------------------------
301
+ /**
302
+ * Build the auto-research task prompt that will be sent to pi's stdin.
303
+ *
304
+ * The prompt instructs the auto-research subagent to:
305
+ * 1. Collect context about the project
306
+ * 2. Analyze what extensions would be useful
307
+ * 3. Generate extension definitions
308
+ * 4. Report results as structured JSON lines
309
+ */
310
+ function buildAutoResearchTask(projectPath, projectName) {
311
+ return JSON.stringify({
312
+ type: "auto_research",
313
+ projectPath,
314
+ projectName,
315
+ instructions: `You are an auto-research agent. Analyze the project at "${projectPath}" named "${projectName}" to determine what custom pi coding agent extensions would accelerate development.
316
+
317
+ Follow these steps:
318
+
319
+ 1. **Context Collection** (emit progress: phase="context_collecting")
320
+ - Read the project's package.json, tsconfig.json, and any config files
321
+ - List the source directory structure (src/, packages/, etc.)
322
+ - Read existing .pi/extensions/ if any exist
323
+ - Note the tech stack (frameworks, databases, tools)
324
+ - Check git state (branch, recent commits) for workflow patterns
325
+
326
+ 2. **Analysis** (emit progress: phase="context_analyzing")
327
+ - Identify repetitive patterns in the codebase
328
+ - Find areas where automation would save developer time
329
+ - Consider common workflows: testing, linting, deployment, code review
330
+ - Evaluate which AI-powered extensions (LLM calls) would add value
331
+ - Prioritize extensions by impact vs. implementation complexity
332
+
333
+ 3. **Extension Generation** (emit progress: phase="extension_generating")
334
+ Generate .ts extension files under .pi/extensions/auto-research/ for each high-value extension. For each extension, emit:
335
+ {"type":"extension_generated","name":"...","path":"...","description":"...","usesLLM":bool,"fileCount":n}
336
+
337
+ Extension categories to consider:
338
+ A. Workflow automation (test runner, linter integration, git hooks)
339
+ B. Code generation (component scaffolding, API route generators)
340
+ C. Project-specific tools (database migrations, schema generators)
341
+ D. Quality & review (code reviewer, error explainer, type checker)
342
+ E. Documentation (doc generator, changelog, API reference)
343
+ F. LLM-powered (commit message generator, PR description, architecture advisor)
344
+ G. Stateful (session memory extensions, usage dashboard, adaptive guidelines)
345
+
346
+ 4. **Validation** (emit progress: phase="extension_validating")
347
+ - Verify generated extensions follow pi extension API patterns
348
+ - Ensure imports resolve against available npm packages
349
+
350
+ 5. **Completion**
351
+ When done, emit:
352
+ {"type":"done","extensions":[...]}
353
+ Then exit.
354
+
355
+ IMPORTANT: Output ONLY JSON lines (one per line). Do not emit markdown, explanations, or any other text outside of JSON. Each line must be valid JSON on a single line. Your output will be parsed by a machine, not a human.`,
356
+ });
357
+ }
@@ -41,8 +41,9 @@
41
41
  import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
42
42
  import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
43
43
  import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
44
- import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
44
+ import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryDetails, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
45
45
  import { shutdownState } from "../server/shutdown.js";
46
+ import { handleAutoResearch } from "./auto-research.js";
46
47
  /**
47
48
  * Inline path matcher. Returns `null` for any path/method combination we
48
49
  * don't recognise; the caller turns that into a `404 Unknown route`.
@@ -103,13 +104,15 @@ export function matchRoute(method, path) {
103
104
  return { route: "delete_session", id };
104
105
  return null;
105
106
  }
106
- // /api/sessions/:id/memory and /api/sessions/:id/compact
107
- const sessionActionMatch = /^\/api\/sessions\/([^/]+)\/(memory|compact)$/.exec(cleanPath);
107
+ // /api/sessions/:id/memory/details and /api/sessions/:id/memory and /api/sessions/:id/compact
108
+ const sessionActionMatch = /^\/api\/sessions\/([^/]+)\/(memory(?:\/details)?|compact)$/.exec(cleanPath);
108
109
  if (sessionActionMatch) {
109
110
  const id = decodeURIComponent(sessionActionMatch[1]);
110
111
  const action = sessionActionMatch[2];
111
112
  if (action === "memory" && method === "GET")
112
113
  return { route: "get_session_memory", id };
114
+ if (action === "memory/details" && method === "GET")
115
+ return { route: "get_session_memory_details", id };
113
116
  if (action === "compact" && method === "POST")
114
117
  return { route: "compact_session", id };
115
118
  return null;
@@ -269,6 +272,8 @@ async function dispatchRoute(match, body, deps) {
269
272
  return handleGetSessionDetail(store, id);
270
273
  case "get_session_memory":
271
274
  return handleGetSessionMemoryStatus(store, manager, id);
275
+ case "get_session_memory_details":
276
+ return handleGetSessionMemoryDetails(store, manager, id);
272
277
  case "update_session": {
273
278
  const session = handleUpdateSession(store, id, asObject(body));
274
279
  safePublish(publishMetaEvent, logger, {
@@ -627,3 +632,22 @@ export function detachAllSubscribers(manager, subscribers) {
627
632
  }
628
633
  subscribers.clear();
629
634
  }
635
+ /**
636
+ * Dispatch an `auto_research` frame. Spawns an isolated pi subprocess that:
637
+ * 1. Collects project context (files, existing extensions, git state)
638
+ * 2. Analyzes the project using an LLM (via subagent)
639
+ * 3. Generates extension `.ts` files under `.pi/extensions/auto-research/`
640
+ * 4. Hot-reloads pi so new extensions take effect immediately
641
+ *
642
+ * Progress is streamed back as `ws_event` frames carrying
643
+ * `auto_research_*` ServerEvent types on the `sessionId` channel.
644
+ *
645
+ * Errors are surfaced as `auto_research_error` events; the underlying
646
+ * subprocess is killed on error to prevent zombie processes.
647
+ */
648
+ export function handleAutoResearchFrame(frame, deps) {
649
+ handleAutoResearch({
650
+ projectId: frame.projectId,
651
+ sessionId: frame.sessionId,
652
+ }, deps);
653
+ }
@@ -46,6 +46,12 @@ export function handleGetSessionMemoryStatus(store, manager, id) {
46
46
  throw new NotFoundError("Session not found");
47
47
  return manager.getSessionMemoryStatus(id);
48
48
  }
49
+ export function handleGetSessionMemoryDetails(store, manager, id) {
50
+ const detail = store.getSession(id);
51
+ if (!detail)
52
+ throw new NotFoundError("Session not found");
53
+ return manager.getSessionMemoryDetails(id);
54
+ }
49
55
  export async function handleCompactSession(store, manager, id) {
50
56
  const detail = store.getSession(id);
51
57
  if (!detail)
@@ -42,7 +42,7 @@ import { getMemoryState, isSourceEntry, rawTokensSinceLastBound, rawTokensSinceL
42
42
  import { observationPoolTokens, renderSummary } from "../memory/compaction.js";
43
43
  import { loadConfig } from "../memory/config.js";
44
44
  import { estimateStringTokens } from "../memory/tokens.js";
45
- import { reflectionContent } from "../memory/types.js";
45
+ import { reflectionContent, reflectionId } from "../memory/types.js";
46
46
  import { generateSessionTitle, isDefaultTitle, } from "./title-generator.js";
47
47
  const DEFAULT_BRIDGE_FACTORY = (args) => new PiBridge(args);
48
48
  /** Safety limit for autonomous loop iterations per session. */
@@ -373,6 +373,61 @@ export class SessionStreamManager {
373
373
  }
374
374
  return status;
375
375
  }
376
+ getSessionMemoryDetails(sessionId) {
377
+ const detail = this.store.getSession(sessionId);
378
+ if (!detail)
379
+ throw new Error(`Unknown sessionId: ${sessionId}`);
380
+ const stream = this.streams.get(sessionId);
381
+ const entries = stream?.bridge.getSessionBranch?.();
382
+ let observations = [];
383
+ let pendingObs = [];
384
+ let reflections = [];
385
+ let summary = null;
386
+ if (entries) {
387
+ const memoryState = getMemoryState(entries);
388
+ observations = memoryState.committedObs;
389
+ pendingObs = memoryState.pendingObs;
390
+ reflections = memoryState.reflections;
391
+ summary = renderSummary(reflections, [...observations, ...pendingObs]);
392
+ }
393
+ else {
394
+ const snapshot = this.store.getSessionMemorySnapshot(sessionId);
395
+ if (snapshot) {
396
+ observations = snapshot.details.observations;
397
+ reflections = snapshot.details.reflections;
398
+ summary = snapshot.summary;
399
+ }
400
+ }
401
+ const pendingIds = new Set(pendingObs.map((o) => o.id));
402
+ const wireObservations = [
403
+ ...observations.map((o) => ({
404
+ id: o.id,
405
+ content: o.content,
406
+ timestamp: o.timestamp,
407
+ relevance: o.relevance,
408
+ pending: false,
409
+ })),
410
+ ...pendingObs.map((o) => ({
411
+ id: o.id,
412
+ content: o.content,
413
+ timestamp: o.timestamp,
414
+ relevance: o.relevance,
415
+ pending: true,
416
+ })),
417
+ ];
418
+ const wireReflections = reflections.map((r) => ({
419
+ id: reflectionId(r) ?? "legacy",
420
+ content: reflectionContent(r),
421
+ supportingObservationIds: typeof r === "object" && "supportingObservationIds" in r
422
+ ? r.supportingObservationIds
423
+ : [],
424
+ }));
425
+ return {
426
+ summary,
427
+ observations: wireObservations,
428
+ reflections: wireReflections,
429
+ };
430
+ }
376
431
  async compactSession(sessionId) {
377
432
  if (this.disposed)
378
433
  throw new Error("SessionStreamManager disposed");
@@ -839,14 +894,23 @@ export class SessionStreamManager {
839
894
  const parts = [];
840
895
  if (stream.loopGoal) {
841
896
  parts.push(`[GOAL]: ${stream.loopGoal}`);
842
- parts.push("");
843
897
  }
844
898
  parts.push(stream.loopOriginalPrompt);
845
- if (stream.loopGoal) {
846
- parts.push("");
847
- parts.push("After completing your work, evaluate whether the goal has been FULLY achieved. " +
848
- 'Include <LOOP_DONE> in your response ONLY if you are confident the goal is completely met.');
849
- }
899
+ parts.push("");
900
+ parts.push("--- LOOP INSTRUCTIONS ---\n" +
901
+ "You are running in an iterative loop. Each iteration you see your previous changes\n" +
902
+ "and can improve them further.\n" +
903
+ (stream.loopGoal
904
+ ? "The [GOAL] above defines what you need to achieve.\n"
905
+ : "\n") +
906
+ "CRITICAL: After EVERY response you MUST self-evaluate:\n" +
907
+ "- If the task IS FULLY COMPLETE (nothing meaningful left to do), " +
908
+ "respond with <LOOP_DONE> as the VERY FIRST text in your reply,\n" +
909
+ " followed by a brief summary of what was accomplished.\n" +
910
+ "- If more work remains, continue working WITHOUT <LOOP_DONE>.\n" +
911
+ "- <LOOP_DONE> is the ONLY way to stop the loop. If you do not emit it,\n" +
912
+ " you WILL be prompted again to keep working.\n" +
913
+ "Be decisive: if the task is done, stop. Do not keep polishing.");
850
914
  return parts.join("\n");
851
915
  }
852
916
  async sendNextLoopIteration(stream) {
@@ -1090,7 +1154,10 @@ export class SessionStreamManager {
1090
1154
  // iteratively improves the solution.
1091
1155
  if (stream.loopActive && stream.loopOriginalPrompt) {
1092
1156
  const finishedAssistantText = finishedTurn?.assistantText ?? "";
1093
- if (finishedAssistantText.includes(LOOP_DONE_MARKER)) {
1157
+ // Check for the loop-done marker in the first portion of the
1158
+ // response (the prompt instructs the agent to emit it first).
1159
+ const head = finishedAssistantText.slice(0, 500);
1160
+ if (head.includes(LOOP_DONE_MARKER)) {
1094
1161
  console.log(`[loop] completion marker detected after ${stream.loopIterationCount} iteration(s), stopping`);
1095
1162
  const completedIterations = stream.loopIterationCount;
1096
1163
  stream.loopActive = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,