@hadooppei/hwcode 1.0.19 → 1.0.20
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/.pi/dist/lib/knowledge/evidence.js +9 -1
- package/.pi/dist/lib/knowledge/extractor.js +39 -101
- package/.pi/dist/lib/knowledge/review-worker.js +94 -18
- package/.pi/dist/lib/knowledge/sanitize.js +8 -0
- package/.pi/dist/lib/knowledge/session-scanner.js +56 -12
- package/.pi/dist/lib/runtime/defaults.js +2 -0
- package/.pi/extensions/knowledge.ts +28 -13
- package/.pi/extensions/workflows/cloud/provider-tools.ts +37 -7
- package/.pi/lib/knowledge/evidence.ts +7 -1
- package/.pi/lib/knowledge/extractor.ts +37 -78
- package/.pi/lib/knowledge/review-status.ts +18 -0
- package/.pi/lib/knowledge/review-worker.ts +91 -18
- package/.pi/lib/knowledge/sanitize.ts +10 -0
- package/.pi/lib/knowledge/session-scanner.ts +55 -11
- package/.pi/lib/knowledge/worker-protocol.ts +1 -1
- package/.pi/lib/runtime/defaults.ts +2 -0
- package/.pi/lib/workflows/cloud/process.ts +19 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -200,6 +200,9 @@ export function buildKnowledgeEvidence(entries: SessionEntry[]): KnowledgeEviden
|
|
|
200
200
|
const role = typeof message.role === "string" ? message.role : "";
|
|
201
201
|
const text = textContent(content).trim();
|
|
202
202
|
if (role === "user" && text) {
|
|
203
|
+
// Skill bodies and workflow activation envelopes are injected as user-role messages by
|
|
204
|
+
// the host. They are execution context, not an explicit human directive to persist.
|
|
205
|
+
if (containsInjectedSkill(text)) return;
|
|
203
206
|
evidence.push({
|
|
204
207
|
id: evidenceId(entryId, "user"), entryId, timestamp, kind: userKind(text),
|
|
205
208
|
subject: "User requirement or correction", outcome: "unknown", excerpt: boundedExcerpt(text, 700),
|
|
@@ -209,6 +212,9 @@ export function buildKnowledgeEvidence(entries: SessionEntry[]): KnowledgeEviden
|
|
|
209
212
|
if (role === "toolResult" && text) {
|
|
210
213
|
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
|
|
211
214
|
const call = calls.get(toolCallId);
|
|
215
|
+
// Recalled knowledge is comparison context, not fresh execution evidence. Treating a
|
|
216
|
+
// lookup result as verification would let knowledge reinforce itself merely by loading it.
|
|
217
|
+
if (call?.name === "hwcode_knowledge_lookup") return;
|
|
212
218
|
const outcome = outcomeFor(message, text);
|
|
213
219
|
const callText = call ? `[call] ${call.name}: ${compactArguments(call.args)}\n` : "";
|
|
214
220
|
evidence.push({
|
|
@@ -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 {
|
|
@@ -65,25 +66,21 @@ export function buildKnowledgeExtractionPrompt(
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
export function parseKnowledgeReviewEnvelope(text: string): KnowledgeReviewEnvelope {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
};
|
|
84
|
-
} catch {
|
|
85
|
-
return { episodeDecisions: [], candidates: parseCandidateEnvelope(text) };
|
|
86
|
-
}
|
|
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
|
+
};
|
|
87
84
|
}
|
|
88
85
|
|
|
89
86
|
function parseEnvelopeObject(text: string): Record<string, unknown> {
|
|
@@ -91,68 +88,30 @@ function parseEnvelopeObject(text: string): Record<string, unknown> {
|
|
|
91
88
|
const unfenced = trimmed.replace(/^```(?:json)?\s*/iu, "").replace(/\s*```$/u, "");
|
|
92
89
|
const start = unfenced.indexOf("{");
|
|
93
90
|
const end = unfenced.lastIndexOf("}");
|
|
94
|
-
if (start < 0 || end <= start)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
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;
|
|
91
|
+
if (start < 0 || end <= start) {
|
|
92
|
+
throw new Error([
|
|
93
|
+
"Knowledge reviewer did not return a complete JSON object",
|
|
94
|
+
`responseChars=${unfenced.length}`,
|
|
95
|
+
`responseExcerpt=${JSON.stringify(compactKnowledgeText(unfenced, 1_200))}`,
|
|
96
|
+
].join("; "));
|
|
97
|
+
}
|
|
98
|
+
const payload = unfenced.slice(start, end + 1);
|
|
105
99
|
try {
|
|
106
|
-
|
|
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);
|
|
100
|
+
return JSON.parse(payload) as Record<string, unknown>;
|
|
109
101
|
} catch (error) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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;
|
|
102
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
103
|
+
const offsetMatch = /position\s+(\d+)/iu.exec(message);
|
|
104
|
+
const offset = offsetMatch ? Number.parseInt(offsetMatch[1]!, 10) : -1;
|
|
105
|
+
const excerptStart = offset >= 0 ? Math.max(0, offset - 600) : 0;
|
|
106
|
+
const excerptEnd = offset >= 0 ? Math.min(payload.length, offset + 600) : Math.min(payload.length, 1_200);
|
|
107
|
+
const excerpt = compactKnowledgeText(payload.slice(excerptStart, excerptEnd), 1_200);
|
|
108
|
+
throw new Error([
|
|
109
|
+
`Knowledge reviewer returned invalid JSON: ${message}`,
|
|
110
|
+
`responseChars=${payload.length}`,
|
|
111
|
+
offset >= 0 ? `errorOffset=${offset}` : undefined,
|
|
112
|
+
`responseExcerpt=${JSON.stringify(excerpt)}`,
|
|
113
|
+
].filter(Boolean).join("; "));
|
|
154
114
|
}
|
|
155
|
-
return -1;
|
|
156
115
|
}
|
|
157
116
|
|
|
158
117
|
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 {
|
|
@@ -7,7 +7,7 @@ import { parentPort, workerData } from "node:worker_threads";
|
|
|
7
7
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
8
8
|
import { userRuntimePaths } from "../runtime/paths.ts";
|
|
9
9
|
import { parseKnowledgeReviewEnvelope } from "./extractor.ts";
|
|
10
|
-
import { type KnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.ts";
|
|
10
|
+
import { readKnowledgeReviewStatus, type KnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.ts";
|
|
11
11
|
import { sanitizeKnowledgeText } from "./sanitize.ts";
|
|
12
12
|
import { findNextReviewTask } from "./session-scanner.ts";
|
|
13
13
|
import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.ts";
|
|
@@ -59,21 +59,44 @@ function publishReviewStatus(): void {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function boundedError(error: unknown): string {
|
|
62
|
-
return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error))
|
|
62
|
+
return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error))
|
|
63
|
+
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxFailureDetailChars);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clearSupersededReviewState(task: KnowledgeReviewTask): void {
|
|
67
|
+
if (!reviewStatus) return;
|
|
68
|
+
for (const field of ["blockedReviews", "reviewFailures"] as const) {
|
|
69
|
+
const records = reviewStatus[field];
|
|
70
|
+
if (!records) continue;
|
|
71
|
+
for (const [reviewKey, record] of Object.entries(records)) {
|
|
72
|
+
if (record.sessionKey === task.sessionKey && reviewKey !== task.reviewKey) {
|
|
73
|
+
delete records[reviewKey];
|
|
74
|
+
reviewAttempts.delete(reviewKey);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (Object.keys(records).length === 0) reviewStatus[field] = undefined;
|
|
78
|
+
}
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
function beginReview(task: KnowledgeReviewTask): void {
|
|
82
|
+
clearSupersededReviewState(task);
|
|
66
83
|
activeAttempt = (reviewAttempts.get(task.reviewKey) ?? 0) + 1;
|
|
67
84
|
reviewAttempts.set(task.reviewKey, activeAttempt);
|
|
68
85
|
activeStartedAt = Date.now();
|
|
69
86
|
clearTimeout(activeDeadlineTimer);
|
|
70
87
|
activeDeadlineTimer = setTimeout(() => {
|
|
71
88
|
if (activeTask?.requestId !== task.requestId) return;
|
|
72
|
-
const error =
|
|
73
|
-
|
|
89
|
+
const error = [
|
|
90
|
+
`knowledge-review-result-timeout after ${KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000}ms`,
|
|
91
|
+
`reviewKey=${task.reviewKey}`,
|
|
92
|
+
`deltaChars=${task.delta.length}`,
|
|
93
|
+
`evidenceItems=${task.evidence?.length ?? 0}`,
|
|
94
|
+
`episodeItems=${task.episodes?.length ?? 0}`,
|
|
95
|
+
].join("; ");
|
|
96
|
+
const terminal = finishReview(task, "failed", { error });
|
|
74
97
|
activeTask = undefined;
|
|
75
98
|
send({ type: "review_cancel", requestId: task.requestId, reason: error });
|
|
76
|
-
send({ type: "review_failed", requestId: task.requestId, error });
|
|
99
|
+
send({ type: "review_failed", requestId: task.requestId, error, terminal });
|
|
77
100
|
}, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000);
|
|
78
101
|
activeDeadlineTimer.unref();
|
|
79
102
|
if (reviewStatus) {
|
|
@@ -93,24 +116,59 @@ function finishReview(
|
|
|
93
116
|
task: KnowledgeReviewTask,
|
|
94
117
|
outcome: "saved" | "failed",
|
|
95
118
|
detail: { generationId?: string; result?: PersistKnowledgeResult; error?: unknown },
|
|
96
|
-
):
|
|
119
|
+
): boolean {
|
|
97
120
|
clearTimeout(activeDeadlineTimer);
|
|
98
121
|
activeDeadlineTimer = undefined;
|
|
99
|
-
if (!reviewStatus) return;
|
|
122
|
+
if (!reviewStatus) return false;
|
|
123
|
+
const error = detail.error === undefined ? undefined : boundedError(detail.error);
|
|
124
|
+
const exhausted = outcome === "failed" && activeAttempt >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxAttempts;
|
|
125
|
+
const completedAt = new Date().toISOString();
|
|
126
|
+
const durationMs = Math.max(0, Date.now() - activeStartedAt);
|
|
100
127
|
reviewStatus.activeReview = undefined;
|
|
101
128
|
reviewStatus.lastReview = {
|
|
102
129
|
reviewKey: task.reviewKey,
|
|
103
130
|
sessionKey: task.sessionKey,
|
|
104
131
|
attempt: activeAttempt,
|
|
105
132
|
outcome,
|
|
106
|
-
completedAt
|
|
107
|
-
durationMs
|
|
133
|
+
completedAt,
|
|
134
|
+
durationMs,
|
|
108
135
|
generationId: detail.generationId,
|
|
109
136
|
result: detail.result,
|
|
110
|
-
error
|
|
137
|
+
error,
|
|
111
138
|
};
|
|
112
|
-
if (outcome === "saved")
|
|
139
|
+
if (outcome === "saved") {
|
|
140
|
+
reviewAttempts.delete(task.reviewKey);
|
|
141
|
+
delete reviewStatus.reviewFailures?.[task.reviewKey];
|
|
142
|
+
clearSupersededReviewState(task);
|
|
143
|
+
} else {
|
|
144
|
+
const reviewFailures = reviewStatus.reviewFailures ?? {};
|
|
145
|
+
const prior = reviewFailures[task.reviewKey];
|
|
146
|
+
reviewFailures[task.reviewKey] = {
|
|
147
|
+
sessionKey: task.sessionKey,
|
|
148
|
+
sessionFileHash: task.sessionFileHash,
|
|
149
|
+
attempts: [...(prior?.attempts ?? []), {
|
|
150
|
+
attempt: activeAttempt,
|
|
151
|
+
failedAt: completedAt,
|
|
152
|
+
durationMs,
|
|
153
|
+
error: error ?? "unknown-review-failure",
|
|
154
|
+
}].slice(-KNOWLEDGE_RUNTIME_DEFAULTS.review.maxAttempts),
|
|
155
|
+
};
|
|
156
|
+
reviewStatus.reviewFailures = Object.fromEntries(Object.entries(reviewFailures).slice(-100));
|
|
157
|
+
}
|
|
158
|
+
if (exhausted) {
|
|
159
|
+
const blockedReviews = reviewStatus.blockedReviews ?? {};
|
|
160
|
+
blockedReviews[task.reviewKey] = {
|
|
161
|
+
reviewKey: task.reviewKey,
|
|
162
|
+
sessionKey: task.sessionKey,
|
|
163
|
+
sessionFileHash: task.sessionFileHash,
|
|
164
|
+
blockedAt: new Date().toISOString(),
|
|
165
|
+
attempts: activeAttempt,
|
|
166
|
+
error: error ?? "unknown-review-failure",
|
|
167
|
+
};
|
|
168
|
+
reviewStatus.blockedReviews = Object.fromEntries(Object.entries(blockedReviews).slice(-100));
|
|
169
|
+
}
|
|
113
170
|
publishReviewStatus();
|
|
171
|
+
return exhausted;
|
|
114
172
|
}
|
|
115
173
|
|
|
116
174
|
function deferReview(task: KnowledgeReviewTask, reason: string): void {
|
|
@@ -155,7 +213,12 @@ function scheduleElection(delay = Math.floor(Math.random() * 500)): void {
|
|
|
155
213
|
}
|
|
156
214
|
|
|
157
215
|
function stepDown(reason: string): void {
|
|
158
|
-
if (activeTask)
|
|
216
|
+
if (activeTask) {
|
|
217
|
+
const attempts = reviewAttempts.get(activeTask.reviewKey) ?? 1;
|
|
218
|
+
if (attempts <= 1) reviewAttempts.delete(activeTask.reviewKey);
|
|
219
|
+
else reviewAttempts.set(activeTask.reviewKey, attempts - 1);
|
|
220
|
+
send({ type: "review_cancel", requestId: activeTask.requestId, reason });
|
|
221
|
+
}
|
|
159
222
|
if (reviewStatus) {
|
|
160
223
|
reviewStatus.state = "stepped-down";
|
|
161
224
|
reviewStatus.stepDownReason = reason;
|
|
@@ -260,8 +323,16 @@ function becomeLeader(server: Server): void {
|
|
|
260
323
|
scheduleElection();
|
|
261
324
|
});
|
|
262
325
|
writeLeaderMetadata(electedAt);
|
|
326
|
+
const priorStatus = readKnowledgeReviewStatus(home);
|
|
327
|
+
reviewAttempts.clear();
|
|
328
|
+
for (const [reviewKey, failure] of Object.entries(priorStatus?.reviewFailures ?? {})) {
|
|
329
|
+
const attempt = Math.max(0, ...failure.attempts.map((item) => item.attempt));
|
|
330
|
+
if (attempt > 0) reviewAttempts.set(reviewKey, attempt);
|
|
331
|
+
}
|
|
263
332
|
reviewStatus = {
|
|
264
333
|
version: 1, protocolVersion: 3, state: "leader", pid: process.pid, leaderToken, electedAt, updatedAt: electedAt,
|
|
334
|
+
blockedReviews: priorStatus?.blockedReviews,
|
|
335
|
+
reviewFailures: priorStatus?.reviewFailures,
|
|
265
336
|
};
|
|
266
337
|
publishReviewStatus();
|
|
267
338
|
send({ type: "leadership", state: "leader", leaderToken });
|
|
@@ -296,7 +367,8 @@ async function scanForReview(): Promise<void> {
|
|
|
296
367
|
}
|
|
297
368
|
try {
|
|
298
369
|
const manifest = loadCurrentManifest(home);
|
|
299
|
-
const
|
|
370
|
+
const blockedReviewKeys = new Set(Object.keys(reviewStatus?.blockedReviews ?? {}));
|
|
371
|
+
const task = findNextReviewTask(sessionsRoot, manifest.reviews, Date.now(), blockedReviewKeys);
|
|
300
372
|
if (!task) return;
|
|
301
373
|
activeTask = task;
|
|
302
374
|
beginReview(task);
|
|
@@ -315,13 +387,14 @@ async function scanForReview(): Promise<void> {
|
|
|
315
387
|
}
|
|
316
388
|
send({ type: "review_request", leaderToken, requestId: task.requestId, task });
|
|
317
389
|
} catch (error) {
|
|
318
|
-
|
|
319
|
-
|
|
390
|
+
const failedTask = activeTask;
|
|
391
|
+
const terminal = failedTask ? finishReview(failedTask, "failed", { error }) : true;
|
|
392
|
+
if (!failedTask && reviewStatus) {
|
|
320
393
|
reviewStatus.lastScanError = { at: new Date().toISOString(), error: boundedError(error) };
|
|
321
394
|
publishReviewStatus();
|
|
322
395
|
}
|
|
323
396
|
activeTask = undefined;
|
|
324
|
-
send({ type: "review_failed", error: boundedError(error) });
|
|
397
|
+
send({ type: "review_failed", requestId: failedTask?.requestId, error: boundedError(error), terminal });
|
|
325
398
|
}
|
|
326
399
|
}
|
|
327
400
|
|
|
@@ -342,8 +415,8 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
|
|
|
342
415
|
}
|
|
343
416
|
committed = true;
|
|
344
417
|
} catch (error) {
|
|
345
|
-
finishReview(task, "failed", { error });
|
|
346
|
-
send({ type: "review_failed", requestId: task.requestId, error: boundedError(error) });
|
|
418
|
+
const terminal = finishReview(task, "failed", { error });
|
|
419
|
+
send({ type: "review_failed", requestId: task.requestId, error: boundedError(error), terminal });
|
|
347
420
|
} finally {
|
|
348
421
|
activeTask = undefined;
|
|
349
422
|
if (committed) queueMicrotask(() => void scanForReview());
|
|
@@ -3,6 +3,16 @@ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/giu;
|
|
|
3
3
|
const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
|
|
4
4
|
const SECRET_ASSIGNMENT_RE = /\b(access[_-]?key|secret(?:[_-]?(?:access|key))?|password|passwd|token|credential|client[_-]?secret)\b(\s*[:=]\s*)[^\s,;]+/giu;
|
|
5
5
|
const URL_CREDENTIAL_RE = /(https?:\/\/)[^\s/@:]+:[^\s/@]+@/giu;
|
|
6
|
+
const INJECTED_SKILL_BLOCK_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
|
|
7
|
+
const INJECTED_SKILL_MARKER_RE = /<skill\b[^>]*>/iu;
|
|
8
|
+
|
|
9
|
+
export function containsInjectedSkill(value: string): boolean {
|
|
10
|
+
return INJECTED_SKILL_MARKER_RE.test(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function stripInjectedSkillBlocks(value: string): string {
|
|
14
|
+
return value.replace(INJECTED_SKILL_BLOCK_RE, "");
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
export function sanitizeKnowledgeText(value: string): string {
|
|
8
18
|
return value
|
|
@@ -10,7 +10,7 @@ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
|
10
10
|
import { canonicalizeDirectory } from "../working-directory.ts";
|
|
11
11
|
import { buildKnowledgeEpisodes, buildKnowledgeEvidence } from "./evidence.ts";
|
|
12
12
|
import { knowledgeDeltaDigest } from "./extractor.ts";
|
|
13
|
-
import { sanitizeKnowledgeText } from "./sanitize.ts";
|
|
13
|
+
import { containsInjectedSkill, sanitizeKnowledgeText, stripInjectedSkillBlocks } from "./sanitize.ts";
|
|
14
14
|
import { projectKnowledgeKey } from "./store.ts";
|
|
15
15
|
import type { KnowledgeReviewCursor, KnowledgeReviewTask } from "./types.ts";
|
|
16
16
|
|
|
@@ -22,7 +22,6 @@ interface ReviewPiece {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
|
|
25
|
-
const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
|
|
26
25
|
const SAFE_KNOWLEDGE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
|
|
27
26
|
const MAX_RECALL_QUERY_CHARS = 4_000;
|
|
28
27
|
const MAX_LOADED_KNOWLEDGE_IDS = 8;
|
|
@@ -54,8 +53,13 @@ function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefine
|
|
|
54
53
|
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
|
|
55
54
|
const message = entry.message as unknown as Record<string, unknown>;
|
|
56
55
|
const role = typeof message.role === "string" ? message.role : "message";
|
|
57
|
-
const
|
|
56
|
+
const rawContent = textContent(message.content);
|
|
57
|
+
const injectedContext = role === "user" && containsInjectedSkill(rawContent);
|
|
58
|
+
const content = sanitizeKnowledgeText(injectedContext ? stripInjectedSkillBlocks(rawContent) : rawContent);
|
|
58
59
|
if (!content) return undefined;
|
|
60
|
+
if (injectedContext) {
|
|
61
|
+
return { index, priority: 3, category: "assistant", text: `[injected_context]\n${content.slice(0, 4_000)}` };
|
|
62
|
+
}
|
|
59
63
|
const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
|
|
60
64
|
: role === "toolResult" ? 4 : 3;
|
|
61
65
|
const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
|
|
@@ -63,8 +67,39 @@ function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefine
|
|
|
63
67
|
return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
|
|
64
68
|
}
|
|
65
69
|
|
|
66
|
-
function
|
|
67
|
-
const
|
|
70
|
+
function entryId(entry: SessionEntry): string | undefined {
|
|
71
|
+
const value = (entry as unknown as Record<string, unknown>).id;
|
|
72
|
+
return typeof value === "string" ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function knowledgeLookupResultEntryIds(entries: SessionEntry[]): Set<string> {
|
|
76
|
+
const lookupCalls = new Set<string>();
|
|
77
|
+
const resultEntries = new Set<string>();
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const message = messageRecord(entry);
|
|
80
|
+
const content = message?.content;
|
|
81
|
+
if (Array.isArray(content)) {
|
|
82
|
+
for (const raw of content) {
|
|
83
|
+
if (!raw || typeof raw !== "object") continue;
|
|
84
|
+
const item = raw as Record<string, unknown>;
|
|
85
|
+
if (item.type === "toolCall" && item.name === "hwcode_knowledge_lookup" && typeof item.id === "string") {
|
|
86
|
+
lookupCalls.add(item.id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (message?.role === "toolResult" && typeof message.toolCallId === "string" && lookupCalls.has(message.toolCallId)) {
|
|
91
|
+
const id = entryId(entry);
|
|
92
|
+
if (id) resultEntries.add(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return resultEntries;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildDelta(entries: SessionEntry[], excludedEntryIds: ReadonlySet<string> = new Set()): string {
|
|
99
|
+
const pieces = entries.map((entry, index) => {
|
|
100
|
+
const id = entryId(entry);
|
|
101
|
+
return id && excludedEntryIds.has(id) ? undefined : reviewPiece(entry, index);
|
|
102
|
+
}).filter((piece): piece is ReviewPiece => Boolean(piece));
|
|
68
103
|
const selected: ReviewPiece[] = [];
|
|
69
104
|
const categoryRemaining: Record<ReviewPiece["category"], number> = {
|
|
70
105
|
user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
|
|
@@ -87,8 +122,11 @@ function buildDelta(entries: SessionEntry[]): string {
|
|
|
87
122
|
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
|
|
88
123
|
}
|
|
89
124
|
|
|
90
|
-
function buildValidationContext(entries: SessionEntry[]): string {
|
|
91
|
-
const pieces = entries.map(
|
|
125
|
+
function buildValidationContext(entries: SessionEntry[], excludedEntryIds: ReadonlySet<string> = new Set()): string {
|
|
126
|
+
const pieces = entries.map((entry, index) => {
|
|
127
|
+
const id = entryId(entry);
|
|
128
|
+
return id && excludedEntryIds.has(id) ? undefined : reviewPiece(entry, index);
|
|
129
|
+
}).filter((piece): piece is ReviewPiece => Boolean(piece));
|
|
92
130
|
const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
|
|
93
131
|
const evidence = pieces.filter((piece) => (
|
|
94
132
|
(piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
|
|
@@ -183,7 +221,9 @@ function buildRecallQuery(entries: SessionEntry[], ids: string[]): string {
|
|
|
183
221
|
for (const entry of entries.slice().reverse()) {
|
|
184
222
|
const message = messageRecord(entry);
|
|
185
223
|
if (!message || message.role !== "user") continue;
|
|
186
|
-
const
|
|
224
|
+
const raw = textContent(message.content);
|
|
225
|
+
if (containsInjectedSkill(raw)) continue;
|
|
226
|
+
const text = compactRecallValue(raw, 800);
|
|
187
227
|
if (text) lines.push(`User request: ${text}`);
|
|
188
228
|
if (lines.filter((line) => line.startsWith("User request:")).length >= 2) break;
|
|
189
229
|
}
|
|
@@ -245,10 +285,13 @@ export function readReviewTask(
|
|
|
245
285
|
const evidenceWindow = cursorIndex >= 0 ? branch.slice(Math.max(0, cursorIndex - 40)) : pending;
|
|
246
286
|
const evidence = buildKnowledgeEvidence(evidenceWindow).filter((item) => pendingEntryIds.has(item.entryId));
|
|
247
287
|
const episodes = buildKnowledgeEpisodes(evidence);
|
|
248
|
-
const
|
|
288
|
+
const lookupResultEntryIds = knowledgeLookupResultEntryIds(branch);
|
|
289
|
+
const delta = buildDelta(pending, lookupResultEntryIds);
|
|
249
290
|
const loadedIds = loadedKnowledgeIds(pending);
|
|
250
291
|
const recallQuery = buildRecallQuery(pending, loadedIds);
|
|
251
|
-
const context = cursorIndex >= 0
|
|
292
|
+
const context = cursorIndex >= 0
|
|
293
|
+
? buildValidationContext(branch.slice(0, cursorIndex + 1), lookupResultEntryIds)
|
|
294
|
+
: "";
|
|
252
295
|
const firstEntryId = pending[0].id;
|
|
253
296
|
const lastEntryId = pending[pending.length - 1].id;
|
|
254
297
|
const deltaDigest = knowledgeDeltaDigest([
|
|
@@ -272,6 +315,7 @@ export function findNextReviewTask(
|
|
|
272
315
|
sessionsRoot: string,
|
|
273
316
|
reviews: Record<string, KnowledgeReviewCursor>,
|
|
274
317
|
now = Date.now(),
|
|
318
|
+
excludedReviewKeys: ReadonlySet<string> = new Set(),
|
|
275
319
|
): KnowledgeReviewTask | undefined {
|
|
276
320
|
const files = discoverSessionFiles(sessionsRoot).map((path) => {
|
|
277
321
|
try { return { path, mtimeMs: statSync(path).mtimeMs }; } catch { return undefined; }
|
|
@@ -283,7 +327,7 @@ export function findNextReviewTask(
|
|
|
283
327
|
let task = readReviewTask(file.path, cursor, now);
|
|
284
328
|
const stableCursor = task ? reviews[task.sessionKey] : undefined;
|
|
285
329
|
if (task && stableCursor && stableCursor !== cursor) task = readReviewTask(file.path, stableCursor, now);
|
|
286
|
-
if (task) return task;
|
|
330
|
+
if (task && !excludedReviewKeys.has(task.reviewKey)) return task;
|
|
287
331
|
}
|
|
288
332
|
return undefined;
|
|
289
333
|
}
|
|
@@ -13,7 +13,7 @@ export type KnowledgeWorkerOutput =
|
|
|
13
13
|
| { type: "review_request"; leaderToken: string; requestId: string; task: KnowledgeReviewTask }
|
|
14
14
|
| { type: "review_cancel"; requestId: string; reason: string }
|
|
15
15
|
| { type: "review_saved"; requestId: string; generationId?: string; result: PersistKnowledgeResult }
|
|
16
|
-
| { type: "review_failed"; requestId?: string; error: string }
|
|
16
|
+
| { type: "review_failed"; requestId?: string; error: string; terminal?: boolean }
|
|
17
17
|
| { type: "generation_changed"; generationId: string };
|
|
18
18
|
|
|
19
19
|
export interface CoordinatorBroadcast {
|
|
@@ -32,6 +32,8 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
|
|
|
32
32
|
idleMs: 60_000,
|
|
33
33
|
capabilityPollMs: 5_000,
|
|
34
34
|
modelTimeoutMs: 180_000,
|
|
35
|
+
maxAttempts: 2,
|
|
36
|
+
maxFailureDetailChars: 4_000,
|
|
35
37
|
maxDeltaChars: 12_000,
|
|
36
38
|
maxPriorContextChars: 4_000,
|
|
37
39
|
maxNarrativeChars: 6_000,
|
|
@@ -18,6 +18,25 @@ export interface ProcessResult {
|
|
|
18
18
|
spawnErrorCode?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const BRACKETED_PROVIDER_ERROR_RE = /\[(?:USE|CLI|AUTH|HTTP|SERVER|SYSTEM|SDK)_ERROR\][^\r\n]*/iu;
|
|
22
|
+
const STRUCTURED_PROVIDER_FAILURE_RE = /"kind"\s*:\s*"Status"[\s\S]{0,500}"status"\s*:\s*"(?:failure|failed|error)"/iu;
|
|
23
|
+
const STRUCTURED_ERROR_CODE_RE = /"(?:errorCode|error_code|code)"\s*:\s*(?:"[^"\r\n]+"|\d+)/iu;
|
|
24
|
+
|
|
25
|
+
/** Detect CLIs that report an API failure in output while incorrectly exiting with code 0. */
|
|
26
|
+
export function cloudProviderFailureReason(stdout: string, stderr: string): string | undefined {
|
|
27
|
+
const output = `${stderr}\n${stdout}`.trim();
|
|
28
|
+
if (!output) return undefined;
|
|
29
|
+
const bracketed = BRACKETED_PROVIDER_ERROR_RE.exec(output)?.[0]?.trim();
|
|
30
|
+
if (bracketed) return bracketed.slice(0, 2_000);
|
|
31
|
+
if (STRUCTURED_PROVIDER_FAILURE_RE.test(output) && STRUCTURED_ERROR_CODE_RE.test(output)) {
|
|
32
|
+
const status = STRUCTURED_PROVIDER_FAILURE_RE.exec(output)?.[0];
|
|
33
|
+
const code = STRUCTURED_ERROR_CODE_RE.exec(output)?.[0];
|
|
34
|
+
const message = /"message"\s*:\s*"([^"\r\n]+)"/iu.exec(output)?.[1];
|
|
35
|
+
return [status, code, message].filter(Boolean).join("; ").slice(0, 2_000);
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
function captureOutput(current: string, chunk: Buffer): string {
|
|
22
41
|
if (Buffer.byteLength(current, "utf8") >= MAX_CAPTURE_BYTES) return current;
|
|
23
42
|
const remaining = MAX_CAPTURE_BYTES - Buffer.byteLength(current, "utf8");
|