@hadooppei/hwcode 1.0.19 → 1.0.22

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.
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { existsSync } from "node:fs";
3
2
  import { dirname } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
@@ -22,6 +21,7 @@ interface ActiveReview {
22
21
  leaderToken: string;
23
22
  controller: AbortController;
24
23
  abortReason?: string;
24
+ diagnostic?: string;
25
25
  }
26
26
 
27
27
  interface ProcessKnowledgeRuntime {
@@ -53,7 +53,7 @@ function compactReviewEvidence(task: KnowledgeReviewTask): {
53
53
  let characters = 0;
54
54
  const add = (item: KnowledgeEvidence | undefined): void => {
55
55
  if (!item || seen.has(item.id) || selected.length >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxEvidenceItems) return;
56
- const compact = { ...item, excerpt: item.excerpt.slice(0, 500) };
56
+ const compact = { ...item, excerpt: item.excerpt.slice(0, 800) };
57
57
  const size = JSON.stringify(compact).length;
58
58
  if (characters + size > KNOWLEDGE_RUNTIME_DEFAULTS.review.maxEvidenceChars) return;
59
59
  selected.push(compact);
@@ -117,7 +117,7 @@ function reportWorkerIssue(error: unknown): void {
117
117
  const prior = runtime.lastWorkerIssue;
118
118
  if (prior?.message === message && now - prior.reportedAt < 60_000) return;
119
119
  runtime.lastWorkerIssue = { message, reportedAt: now };
120
- const rendered = `HWCode knowledge worker failed: ${message}`;
120
+ const rendered = `HWCode knowledge worker failed: ${message.slice(0, 600)}`;
121
121
  if (runtime.context?.hasUI) runtime.context.ui.notify(rendered, "error");
122
122
  else console.error(rendered);
123
123
  }
@@ -162,7 +162,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
162
162
  runtime.activeReview = review;
163
163
  timeout = setTimeout(() => {
164
164
  if (controller.signal.aborted) return;
165
- review!.abortReason = "knowledge-review-model-timeout";
165
+ review!.abortReason = `knowledge-review-model-timeout after ${KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs}ms${review!.diagnostic ? `; ${review!.diagnostic}` : ""}`;
166
166
  controller.abort();
167
167
  }, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
168
168
  timeout.unref();
@@ -182,13 +182,20 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
182
182
  seenRelated.add(entry.id);
183
183
  };
184
184
  const episodeMatches = (message.task.episodes ?? []).map((episode) => (
185
- matchKnowledge(`${episode.title}\n${episode.query}`, applicableCatalog, 2)
185
+ matchKnowledge(
186
+ `${episode.title}\n${episode.query}`,
187
+ applicableCatalog,
188
+ KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
189
+ )
186
190
  ));
187
- for (const matches of episodeMatches.slice(0, 3)) addRelated(matches[0]);
191
+ for (const matches of episodeMatches) addRelated(matches[0]);
188
192
  for (const id of message.task.loadedKnowledgeIds ?? []) {
189
193
  addRelated(applicableCatalog.items.find((entry) => entry.id === id));
190
194
  }
191
- for (const matches of episodeMatches) for (const entry of matches.slice(1)) addRelated(entry);
195
+ const matchDepth = Math.max(0, ...episodeMatches.map((matches) => matches.length));
196
+ for (let rank = 1; rank < matchDepth; rank++) {
197
+ for (const matches of episodeMatches) addRelated(matches[rank]);
198
+ }
192
199
  for (const entry of matchKnowledge(
193
200
  message.task.recallQuery || message.task.delta,
194
201
  applicableCatalog,
@@ -210,6 +217,28 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
210
217
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyChars);
211
218
  return content ? { ...reference, content } : reference;
212
219
  });
220
+ const prompt = buildKnowledgeExtractionPrompt(
221
+ message.task.projectRoot,
222
+ message.task.delta.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxNarrativeChars),
223
+ message.task.context,
224
+ related,
225
+ promptContext.evidence,
226
+ promptContext.episodes,
227
+ );
228
+ const diagnostic = [
229
+ `reviewKey=${message.task.reviewKey}`,
230
+ `model=${ctx.model.provider}/${ctx.model.id}`,
231
+ `promptChars=${prompt.length}`,
232
+ `deltaChars=${message.task.delta.length}`,
233
+ `evidenceItems=${promptContext.evidence.length}`,
234
+ `episodeItems=${promptContext.episodes.length}`,
235
+ `existingItems=${related.length}`,
236
+ ];
237
+ const requestStartedAt = Date.now();
238
+ const setRequestPhase = (phase: string): void => {
239
+ review!.diagnostic = [...diagnostic, `phase=${phase}`, `requestElapsedMs=${Date.now() - requestStartedAt}`].join("; ");
240
+ };
241
+ setRequestPhase("preparing-request");
213
242
  const response = await ctx.modelRegistry.complete(
214
243
  ctx.model,
215
244
  {
@@ -218,29 +247,41 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
218
247
  role: "user",
219
248
  content: [{
220
249
  type: "text",
221
- text: buildKnowledgeExtractionPrompt(
222
- message.task.projectRoot,
223
- message.task.delta.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxNarrativeChars),
224
- message.task.context,
225
- related,
226
- promptContext.evidence,
227
- promptContext.episodes,
228
- ),
250
+ text: prompt,
229
251
  }],
230
252
  timestamp: Date.now(),
231
253
  }],
232
254
  },
233
- { signal: controller.signal, reasoningEffort: "low", cacheRetention: "none", sessionId: randomUUID() },
255
+ {
256
+ signal: controller.signal,
257
+ reasoningEffort: "low",
258
+ cacheRetention: "none",
259
+ temperature: 0,
260
+ maxTokens: KNOWLEDGE_RUNTIME_DEFAULTS.review.maxOutputTokens,
261
+ timeoutMs: KNOWLEDGE_RUNTIME_DEFAULTS.review.requestTimeoutMs,
262
+ maxRetries: 0,
263
+ onPayload: () => { setRequestPhase("awaiting-http-response"); },
264
+ onResponse: (providerResponse: { status: number }) => {
265
+ setRequestPhase(`streaming-response-http-${providerResponse.status}`);
266
+ },
267
+ },
234
268
  );
235
269
  if (controller.signal.aborted) throw new Error(review.abortReason ?? "knowledge-review-interrupted");
270
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
271
+ throw new Error(response.errorMessage ?? `knowledge-review-model-${response.stopReason}`);
272
+ }
236
273
  const raw = response.content.filter((item): item is { type: "text"; text: string } => item.type === "text")
237
274
  .map((item) => item.text).join("\n");
238
275
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
239
276
  } catch (error) {
240
- const reason = review?.controller.signal.aborted
277
+ const baseReason = review?.controller.signal.aborted
241
278
  ? review.abortReason ?? "knowledge-review-interrupted"
242
279
  : error instanceof Error ? error.message : String(error);
243
- if (reason === "model-executor-is-busy" || isExpectedReviewInterruption(reason)) {
280
+ const expectedInterruption = baseReason === "model-executor-is-busy" || isExpectedReviewInterruption(baseReason);
281
+ const reason = review?.diagnostic && !expectedInterruption && !baseReason.includes("reviewKey=")
282
+ ? `${baseReason}; ${review.diagnostic}`
283
+ : baseReason;
284
+ if (expectedInterruption) {
244
285
  post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
245
286
  } else {
246
287
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
@@ -263,7 +304,7 @@ function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
263
304
  cancelActiveReview(message.reason);
264
305
  return;
265
306
  }
266
- if (message.type === "review_failed") reportWorkerIssue(message.error);
307
+ if (message.type === "review_failed" && message.terminal !== false) reportWorkerIssue(message.error);
267
308
  }
268
309
 
269
310
  function ensureWorker(): Worker | undefined {
@@ -8,7 +8,9 @@ import { toolError, toolOk } from "../../../lib/tool-result.ts";
8
8
  import { evaluateCommandArgumentsAccess } from "../../../lib/workspace/access-policy.ts";
9
9
  import { prepareCloudExecution } from "../../../lib/workflows/cloud/adapters.ts";
10
10
  import { cloudExecutionBlockReason, recordStrategyFailure } from "../../../lib/workflows/cloud/execution.ts";
11
- import { runProcess, truncateOutput, type ProcessResult } from "../../../lib/workflows/cloud/process.ts";
11
+ import {
12
+ cloudProviderFailureReason, runProcess, truncateOutput, type ProcessResult,
13
+ } from "../../../lib/workflows/cloud/process.ts";
12
14
  import { CLOUD_EXECUTABLES, classifyCloudOperation, getCloudProvider, redactCredentialValues, type CloudOperation } from "../../../lib/workflows/cloud/providers.ts";
13
15
  import { cloudArtifactDirectory } from "../../../lib/workflows/cloud/workspace.ts";
14
16
  import { WORKFLOW_EXTERNAL_AUDIT_TYPE, cloudDetails } from "../../../lib/workflows/state.ts";
@@ -106,16 +108,44 @@ export function registerCloudProviderTools(pi: ExtensionAPI, runtime: CloudExten
106
108
  : await runProcess(params.command, prepared.args, { cwd: cloudArtifactDirectory(activeState), env: prepared.env, signal });
107
109
  } finally { prepared.cleanup?.(); }
108
110
 
109
- const stdout = truncateOutput(redactCredentialValues(result.stdout, credentials));
110
- const stderr = truncateOutput(redactCredentialValues(result.stderr, credentials));
111
- pi.appendEntry(CLOUD_AUDIT_TYPE, { vendor: details.vendor, command: params.command, operation, intent: params.intent, approach: params.approach, exitCode: result.code, executedAt: new Date().toISOString() });
112
- if (result.code !== 0) {
113
- const failed = recordStrategyFailure(details, params.approach, params.command, stderr.trim() || stdout.trim() || `exit code ${result.code}`, CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches);
111
+ const redactedStdout = redactCredentialValues(result.stdout, credentials);
112
+ const redactedStderr = redactCredentialValues(result.stderr, credentials);
113
+ const providerFailure = cloudProviderFailureReason(redactedStdout, redactedStderr);
114
+ const stdout = truncateOutput(redactedStdout);
115
+ const stderr = truncateOutput(redactedStderr);
116
+ const failureReason = result.code !== 0
117
+ ? stderr.trim() || stdout.trim() || `exit code ${result.code}`
118
+ : providerFailure;
119
+ pi.appendEntry(CLOUD_AUDIT_TYPE, {
120
+ vendor: details.vendor,
121
+ command: params.command,
122
+ operation,
123
+ intent: params.intent,
124
+ approach: params.approach,
125
+ exitCode: result.code,
126
+ outcome: failureReason ? "failed" : "succeeded",
127
+ providerFailure: providerFailure ? safeStepText(providerFailure, credentials) : undefined,
128
+ executedAt: new Date().toISOString(),
129
+ });
130
+ if (failureReason) {
131
+ const failed = recordStrategyFailure(details, params.approach, params.command, failureReason, CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches);
114
132
  runtime.replaceDetails(activeState, failed, failed.terminalFailure ? "failed" : "executing");
115
133
  const suffix = failed.terminalFailure
116
134
  ? "\n\nTERMINAL FAILURE: Three materially different approaches failed. Explain causes and summarize progress and changes. Do not try another cloud operation."
117
135
  : `\n\nDistinct failed approaches: ${failed.failedApproaches.length}/${CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches}.`;
118
- return { content: [{ type: "text" as const, text: `Cloud command failed with exit code ${result.code}.\n${stderr || stdout}${suffix}` }], isError: true, details: { operation, exitCode: result.code, terminalFailure: failed.terminalFailure } };
136
+ const heading = result.code !== 0
137
+ ? `Cloud command failed with exit code ${result.code}.`
138
+ : "Cloud provider reported a failure despite exit code 0.";
139
+ return {
140
+ content: [{ type: "text" as const, text: `${heading}\n${stderr || stdout || providerFailure}${suffix}` }],
141
+ isError: true,
142
+ details: {
143
+ operation,
144
+ exitCode: result.code,
145
+ providerFailure: Boolean(providerFailure),
146
+ terminalFailure: failed.terminalFailure,
147
+ },
148
+ };
119
149
  }
120
150
 
121
151
  const successfulSteps = [...details.successfulSteps, {
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
4
4
 
5
- import { sanitizeKnowledgeText } from "./sanitize.ts";
5
+ import { containsInjectedSkill, sanitizeKnowledgeText } from "./sanitize.ts";
6
6
  import type { KnowledgeEpisode, KnowledgeEvidence, KnowledgeEvidenceKind } from "./types.ts";
7
7
 
8
8
  const FAILURE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|timeout|timed out|unreachable|servfail|refused)\b|not supported|失败|错误|不支持|超时|无法|不可达)/iu;
@@ -99,8 +99,13 @@ function boundedExcerpt(value: string, limit = 900): string {
99
99
  const clean = sanitizeKnowledgeText(value).replace(/\n{3,}/gu, "\n\n").trim();
100
100
  if (clean.length <= limit) return clean;
101
101
  const lines = clean.split("\n");
102
- const significant = lines.filter((line) => FAILURE_RE.test(line) || VERIFICATION_RE.test(line));
103
- const selected = [...lines.slice(0, 3), ...significant.slice(0, 5), ...lines.slice(-3)];
102
+ const priority = (line: string): number => REVERSAL_RE.test(line) ? 0
103
+ : FAILURE_RE.test(line) && VERIFICATION_RE.test(line) ? 1
104
+ : FAILURE_RE.test(line) ? 2 : 3;
105
+ const significant = lines.filter((line) => (
106
+ REVERSAL_RE.test(line) || FAILURE_RE.test(line) || VERIFICATION_RE.test(line)
107
+ )).sort((left, right) => priority(left) - priority(right));
108
+ const selected = [...lines.slice(0, 2), ...significant.slice(0, 7), ...lines.slice(-2)];
104
109
  return [...new Set(selected)].join("\n").slice(0, limit);
105
110
  }
106
111
 
@@ -163,9 +168,8 @@ function evidenceId(entryId: string, suffix: string): string {
163
168
  return `ev-${digest(`${entryId}\0${suffix}`)}`;
164
169
  }
165
170
 
166
- function outcomeFor(message: Record<string, unknown>, text: string): "success" | "failure" {
167
- if (message.isError === true || FAILURE_RE.test(text)) return "failure";
168
- return "success";
171
+ function outcomeFor(message: Record<string, unknown>): "success" | "failure" {
172
+ return message.isError === true ? "failure" : "success";
169
173
  }
170
174
 
171
175
  function userKind(text: string): KnowledgeEvidenceKind {
@@ -200,6 +204,9 @@ export function buildKnowledgeEvidence(entries: SessionEntry[]): KnowledgeEviden
200
204
  const role = typeof message.role === "string" ? message.role : "";
201
205
  const text = textContent(content).trim();
202
206
  if (role === "user" && text) {
207
+ // Skill bodies and workflow activation envelopes are injected as user-role messages by
208
+ // the host. They are execution context, not an explicit human directive to persist.
209
+ if (containsInjectedSkill(text)) return;
203
210
  evidence.push({
204
211
  id: evidenceId(entryId, "user"), entryId, timestamp, kind: userKind(text),
205
212
  subject: "User requirement or correction", outcome: "unknown", excerpt: boundedExcerpt(text, 700),
@@ -209,11 +216,14 @@ export function buildKnowledgeEvidence(entries: SessionEntry[]): KnowledgeEviden
209
216
  if (role === "toolResult" && text) {
210
217
  const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
211
218
  const call = calls.get(toolCallId);
212
- const outcome = outcomeFor(message, text);
219
+ // Recalled knowledge is comparison context, not fresh execution evidence. Treating a
220
+ // lookup result as verification would let knowledge reinforce itself merely by loading it.
221
+ if (call?.name === "hwcode_knowledge_lookup") return;
222
+ const outcome = outcomeFor(message);
213
223
  const callText = call ? `[call] ${call.name}: ${compactArguments(call.args)}\n` : "";
214
224
  evidence.push({
215
225
  id: evidenceId(entryId, toolCallId || "tool"), entryId, timestamp,
216
- kind: outcome === "failure" ? "tool-failure" : VERIFICATION_RE.test(text) ? "verification" : "tool-success",
226
+ kind: outcome === "failure" ? "tool-failure" : "tool-success",
217
227
  subject: evidenceSubject(call, text), operation: operationName(call), outcome,
218
228
  excerpt: boundedExcerpt(`${callText}[result] ${text}`),
219
229
  });
@@ -222,7 +232,7 @@ export function buildKnowledgeEvidence(entries: SessionEntry[]): KnowledgeEviden
222
232
  if (role === "assistant" && text && (FAILURE_RE.test(text) || VERIFICATION_RE.test(text) || REVERSAL_RE.test(text))) {
223
233
  evidence.push({
224
234
  id: evidenceId(entryId, "assistant"), entryId, timestamp,
225
- kind: VERIFICATION_RE.test(text) ? "verification" : "assistant-claim",
235
+ kind: "assistant-claim",
226
236
  subject: "Assistant diagnosis or conclusion", outcome: "unknown", excerpt: boundedExcerpt(text, 800),
227
237
  });
228
238
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
3
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
4
+ import { compactKnowledgeText } from "./sanitize.ts";
4
5
  import type { KnowledgeEpisode, KnowledgeEpisodeDecision, KnowledgeEvidence } from "./types.ts";
5
6
 
6
7
  export interface ExistingKnowledgeContext {
@@ -32,9 +33,11 @@ Treat failed, unsupported, invalid, or corrected operations as negative evidence
32
33
  Use storageHint "rule" only for one short imperative instruction of at most 240 characters. A rule must not contain incident narration, example resource names, IDs, timestamps, or evidence details. Use "topic" for multi-step SOPs and detailed experience.
33
34
  Rank knowledge by future time saved, repeated failures, hypothesis reversals, user corrections, verified resolution, reuse, and novelty. Do not favor the final task result merely because it completed the objective. Every supplied episode scoring 45 or higher must have an episodeDecisions entry, even when skipped.
34
35
  Relevant existing knowledge may be supplied. Cheap recall only proposes related items; it does not prove duplication. Compare execution environment, symptom, and root cause. Use action "reinforce" only when the exact target already covers every claim and there is no new content. Use "extend" for new compatible claims, "correct" when direct evidence disproves old content, "retire" when the whole target is invalid, and "add" for a distinct environment or root cause. Never turn lexical similarity into reinforcement.
36
+ Prefer the most specific existing topic that already covers a claim over a broad conventions or SOP topic.
35
37
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
36
38
  The prior validation context is only for checking facts and contradictions; do not persist it again unless the new delta independently reinforces it. Prefer accumulated workflow successfulSteps and failedApproaches over assistant narration.
37
39
  Every technical claim must have a stable claim key and cite evidenceIds from the supplied evidence ledger. Tool success, tool failure, and verification evidence outrank assistant narration. Do not support a service-wide or universal claim with one operation-specific failure. Evidence for an unrelated parameter error cannot support an API-version claim.
40
+ Keep each candidate atomic: put separable claim sets in separate candidates, omit any claim without direct cited evidence, and prioritize the highest-value supported candidates. One unsupported claim rejects its entire candidate.
38
41
  Each summary must be one complete sentence of at most 160 characters. Keep each topic body under 2200 characters. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered. Return at most 3 candidates.
39
42
  The body must not repeat the title as a Markdown H1. The storage renderer supplies the H1. Replace any necessary example resource value with an obvious placeholder instead of a real project, namespace, image, network, or resource name.
40
43
  Return: {"episodeDecisions":[{"episodeId":"ep-id","decision":"add|reinforce|extend|correct|retire|skip","targetId":"existing-id or omitted","reason":"..."}],"candidates":[{"key":"stable semantic key","identityKey":"platform/runtime/problem/root-cause","targetId":"existing-id or null","episodeIds":["ep-id"],"title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body or only the new section for extend","claims":[{"key":"stable claim key","text":"one precise claim","evidenceIds":["ev-id"]}],"evidenceIds":["ev-id"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|extend|correct|retire","explicitUserDirective":false,"durability":"stable"}]}
@@ -65,25 +68,21 @@ export function buildKnowledgeExtractionPrompt(
65
68
  }
66
69
 
67
70
  export function parseKnowledgeReviewEnvelope(text: string): KnowledgeReviewEnvelope {
68
- try {
69
- const parsed = parseEnvelopeObject(text);
70
- if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
71
- const validDecisions = new Set(["add", "reinforce", "extend", "correct", "retire", "skip"]);
72
- const decisions = Array.isArray(parsed.episodeDecisions)
73
- ? parsed.episodeDecisions.filter((item): item is KnowledgeEpisodeDecision => {
74
- if (!item || typeof item !== "object") return false;
75
- const value = item as Record<string, unknown>;
76
- return typeof value.episodeId === "string" && validDecisions.has(String(value.decision))
77
- && typeof value.reason === "string";
78
- })
79
- : [];
80
- return {
81
- episodeDecisions: decisions,
82
- candidates: parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates),
83
- };
84
- } catch {
85
- return { episodeDecisions: [], candidates: parseCandidateEnvelope(text) };
86
- }
71
+ const parsed = parseEnvelopeObject(text);
72
+ if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
73
+ const validDecisions = new Set(["add", "reinforce", "extend", "correct", "retire", "skip"]);
74
+ const decisions = Array.isArray(parsed.episodeDecisions)
75
+ ? parsed.episodeDecisions.filter((item): item is KnowledgeEpisodeDecision => {
76
+ if (!item || typeof item !== "object") return false;
77
+ const value = item as Record<string, unknown>;
78
+ return typeof value.episodeId === "string" && validDecisions.has(String(value.decision))
79
+ && typeof value.reason === "string";
80
+ })
81
+ : [];
82
+ return {
83
+ episodeDecisions: decisions,
84
+ candidates: parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates),
85
+ };
87
86
  }
88
87
 
89
88
  function parseEnvelopeObject(text: string): Record<string, unknown> {
@@ -91,68 +90,30 @@ function parseEnvelopeObject(text: string): Record<string, unknown> {
91
90
  const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
92
91
  const start = unfenced.indexOf("{");
93
92
  const end = unfenced.lastIndexOf("}");
94
- if (start < 0 || end <= start) throw new Error("Knowledge reviewer did not return a JSON object");
95
- return JSON.parse(unfenced.slice(start, end + 1)) as Record<string, unknown>;
96
- }
97
-
98
- export function parseCandidateEnvelope(text: string): unknown[] {
99
- const trimmed = text.trim();
100
- const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
101
- const start = unfenced.indexOf("{");
102
- const end = unfenced.lastIndexOf("}");
103
- if (start < 0 || end <= start) throw new Error("Knowledge reviewer did not return a JSON object");
104
- let strictError: unknown;
93
+ if (start < 0 || end <= start) {
94
+ throw new Error([
95
+ "Knowledge reviewer did not return a complete JSON object",
96
+ `responseChars=${unfenced.length}`,
97
+ `responseExcerpt=${JSON.stringify(compactKnowledgeText(unfenced, 1_200))}`,
98
+ ].join("; "));
99
+ }
100
+ const payload = unfenced.slice(start, end + 1);
105
101
  try {
106
- const parsed = JSON.parse(unfenced.slice(start, end + 1)) as { candidates?: unknown };
107
- if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
108
- return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
102
+ return JSON.parse(payload) as Record<string, unknown>;
109
103
  } catch (error) {
110
- strictError = error;
111
- }
112
-
113
- const arrayKey = /["']candidates["']\s*:/gu.exec(unfenced);
114
- const arrayStart = arrayKey ? unfenced.indexOf("[", arrayKey.index + arrayKey[0].length) : -1;
115
- if (arrayStart >= 0) {
116
- const candidates: unknown[] = [];
117
- let inString = false;
118
- let escaped = false;
119
- for (let index = arrayStart + 1; index < unfenced.length && candidates.length < KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates; index++) {
120
- const character = unfenced[index];
121
- if (inString) {
122
- if (escaped) escaped = false;
123
- else if (character === "\\") escaped = true;
124
- else if (character === '"') inString = false;
125
- continue;
126
- }
127
- if (character === '"') { inString = true; continue; }
128
- if (character !== "{") continue;
129
- const objectEnd = balancedObjectEnd(unfenced, index);
130
- if (objectEnd < 0) break;
131
- try { candidates.push(JSON.parse(unfenced.slice(index, objectEnd + 1))); } catch { /* Salvage other complete candidates. */ }
132
- index = objectEnd;
133
- }
134
- if (candidates.length > 0) return candidates;
135
- }
136
- throw strictError;
137
- }
138
-
139
- function balancedObjectEnd(value: string, start: number): number {
140
- let depth = 0;
141
- let inString = false;
142
- let escaped = false;
143
- for (let index = start; index < value.length; index++) {
144
- const character = value[index];
145
- if (inString) {
146
- if (escaped) escaped = false;
147
- else if (character === "\\") escaped = true;
148
- else if (character === '"') inString = false;
149
- continue;
150
- }
151
- if (character === '"') { inString = true; continue; }
152
- if (character === "{") depth++;
153
- else if (character === "}" && --depth === 0) return index;
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ const offsetMatch = /position\s+(\d+)/iu.exec(message);
106
+ const offset = offsetMatch ? Number.parseInt(offsetMatch[1]!, 10) : -1;
107
+ const excerptStart = offset >= 0 ? Math.max(0, offset - 600) : 0;
108
+ const excerptEnd = offset >= 0 ? Math.min(payload.length, offset + 600) : Math.min(payload.length, 1_200);
109
+ const excerpt = compactKnowledgeText(payload.slice(excerptStart, excerptEnd), 1_200);
110
+ throw new Error([
111
+ `Knowledge reviewer returned invalid JSON: ${message}`,
112
+ `responseChars=${payload.length}`,
113
+ offset >= 0 ? `errorOffset=${offset}` : undefined,
114
+ `responseExcerpt=${JSON.stringify(excerpt)}`,
115
+ ].filter(Boolean).join("; "));
154
116
  }
155
- return -1;
156
117
  }
157
118
 
158
119
  export function knowledgeDeltaDigest(delta: string): string {
@@ -40,6 +40,24 @@ export interface KnowledgeReviewStatus {
40
40
  result?: PersistKnowledgeResult;
41
41
  error?: string;
42
42
  };
43
+ reviewFailures?: Record<string, {
44
+ sessionKey: string;
45
+ sessionFileHash: string;
46
+ attempts: Array<{
47
+ attempt: number;
48
+ failedAt: string;
49
+ durationMs: number;
50
+ error: string;
51
+ }>;
52
+ }>;
53
+ blockedReviews?: Record<string, {
54
+ reviewKey: string;
55
+ sessionKey: string;
56
+ sessionFileHash: string;
57
+ blockedAt: string;
58
+ attempts: number;
59
+ error: string;
60
+ }>;
43
61
  }
44
62
 
45
63
  export function writeKnowledgeReviewStatus(status: KnowledgeReviewStatus, home: string): void {