@hadooppei/hwcode 1.0.10 → 1.0.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.
- package/.pi/dist/lib/knowledge/extractor.js +90 -9
- package/.pi/dist/lib/knowledge/review-status.js +20 -0
- package/.pi/dist/lib/knowledge/review-worker.js +105 -5
- package/.pi/dist/lib/knowledge/sanitize.js +20 -0
- package/.pi/dist/lib/knowledge/session-scanner.js +46 -9
- package/.pi/dist/lib/knowledge/store.js +83 -10
- package/.pi/dist/lib/runtime/defaults.js +9 -5
- package/.pi/dist/lib/runtime/paths.js +1 -0
- package/.pi/extensions/knowledge.ts +37 -16
- package/.pi/lib/knowledge/extractor.ts +84 -8
- package/.pi/lib/knowledge/review-status.ts +55 -0
- package/.pi/lib/knowledge/review-worker.ts +106 -6
- package/.pi/lib/knowledge/sanitize.ts +20 -0
- package/.pi/lib/knowledge/session-scanner.ts +43 -9
- package/.pi/lib/knowledge/store.ts +77 -10
- package/.pi/lib/knowledge/types.ts +4 -0
- package/.pi/lib/runtime/defaults.ts +9 -5
- package/.pi/lib/runtime/paths.ts +2 -0
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
8
8
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
buildKnowledgeExtractionPrompt, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
|
|
11
|
+
buildKnowledgeExtractionPrompt, type ExistingKnowledgeContext, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
|
|
12
12
|
} from "../lib/knowledge/extractor.ts";
|
|
13
13
|
import { matchKnowledge } from "../lib/knowledge/matcher.ts";
|
|
14
14
|
import { loadKnowledgeById, loadKnowledgeSnapshot, projectKnowledgeKey } from "../lib/knowledge/store.ts";
|
|
@@ -94,28 +94,48 @@ function workerEntryUrl(): URL {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review_request" }>): Promise<void> {
|
|
97
|
-
|
|
98
|
-
if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) {
|
|
99
|
-
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "no-configured-model" });
|
|
100
|
-
updateCapability();
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
104
|
-
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: "model-executor-is-busy" });
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
const controller = new AbortController();
|
|
108
|
-
runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
|
|
109
|
-
const timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
|
|
110
|
-
timeout.unref();
|
|
97
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
111
98
|
try {
|
|
99
|
+
const ctx = runtime.context;
|
|
100
|
+
if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) throw new Error("no-configured-model");
|
|
101
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) throw new Error("model-executor-is-busy");
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
|
|
104
|
+
timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
|
|
105
|
+
timeout.unref();
|
|
106
|
+
const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
|
|
107
|
+
const related: ExistingKnowledgeContext[] = matchKnowledge(
|
|
108
|
+
message.task.delta,
|
|
109
|
+
snapshot.catalog,
|
|
110
|
+
KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
|
|
111
|
+
).map((entry, index) => {
|
|
112
|
+
const reference: ExistingKnowledgeContext = {
|
|
113
|
+
id: entry.id,
|
|
114
|
+
title: entry.title,
|
|
115
|
+
summary: entry.summary,
|
|
116
|
+
keywords: entry.keywords,
|
|
117
|
+
track: entry.track,
|
|
118
|
+
};
|
|
119
|
+
if (index >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyItems) return reference;
|
|
120
|
+
const content = loadKnowledgeById(entry.id, message.task.projectKey)?.content
|
|
121
|
+
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyChars);
|
|
122
|
+
return content ? { ...reference, content } : reference;
|
|
123
|
+
});
|
|
112
124
|
const response = await ctx.modelRegistry.complete(
|
|
113
125
|
ctx.model,
|
|
114
126
|
{
|
|
115
127
|
systemPrompt: KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
|
|
116
128
|
messages: [{
|
|
117
129
|
role: "user",
|
|
118
|
-
content: [{
|
|
130
|
+
content: [{
|
|
131
|
+
type: "text",
|
|
132
|
+
text: buildKnowledgeExtractionPrompt(
|
|
133
|
+
message.task.projectRoot,
|
|
134
|
+
message.task.delta,
|
|
135
|
+
message.task.context,
|
|
136
|
+
related,
|
|
137
|
+
),
|
|
138
|
+
}],
|
|
119
139
|
timestamp: Date.now(),
|
|
120
140
|
}],
|
|
121
141
|
},
|
|
@@ -130,6 +150,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
130
150
|
type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
|
|
131
151
|
error: error instanceof Error ? error.message : String(error),
|
|
132
152
|
});
|
|
153
|
+
try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
|
|
133
154
|
} finally {
|
|
134
155
|
clearTimeout(timeout);
|
|
135
156
|
if (runtime.activeReview?.requestId === message.requestId) runtime.activeReview = undefined;
|
|
@@ -2,6 +2,15 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
4
4
|
|
|
5
|
+
export interface ExistingKnowledgeContext {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
keywords: string[];
|
|
10
|
+
track: "rule" | "topic";
|
|
11
|
+
content?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
|
|
6
15
|
Review only the supplied conversation delta. Return strict JSON and no markdown.
|
|
7
16
|
Persist only knowledge that is likely to be useful in future sessions:
|
|
@@ -9,14 +18,32 @@ Persist only knowledge that is likely to be useful in future sessions:
|
|
|
9
18
|
- verified engineering rules, successful procedures, or expensive failed approaches;
|
|
10
19
|
- reusable architecture, testing, debugging, deployment, or operational knowledge.
|
|
11
20
|
Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
Do not persist mutable inventory such as current cloud resources, names, availability, status, timestamps, or account snapshots. Preserve the discovery method or selection rule instead. If useful procedure is mixed with volatile facts, remove the volatile facts.
|
|
22
|
+
Treat failed, unsupported, invalid, or corrected operations as negative evidence. Never present an operation as valid when the supplied evidence says it failed; use only a verified alternative or record an explicit warning. Never invent commands or parameters absent from successful evidence.
|
|
23
|
+
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.
|
|
24
|
+
Relevant existing knowledge may be supplied. If a candidate has the same intent as an existing item, do not add a translated, renamed, or reformatted duplicate. Set targetId to that exact existing ID and action to "reinforce" when the existing content remains correct, or "revise" only when the delta explicitly proves a correction. Use targetId null only for genuinely new knowledge.
|
|
14
25
|
Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
|
|
15
|
-
|
|
26
|
+
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.
|
|
27
|
+
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.
|
|
28
|
+
Return: {"candidates":[{"key":"stable semantic key","targetId":"existing-id or null","title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false,"durability":"stable"}]}
|
|
16
29
|
Return {"candidates":[]} when nothing meets the threshold.`;
|
|
17
30
|
|
|
18
|
-
export function buildKnowledgeExtractionPrompt(
|
|
19
|
-
|
|
31
|
+
export function buildKnowledgeExtractionPrompt(
|
|
32
|
+
projectRoot: string,
|
|
33
|
+
delta: string,
|
|
34
|
+
context = "",
|
|
35
|
+
existing: ExistingKnowledgeContext[] = [],
|
|
36
|
+
): string {
|
|
37
|
+
const related = existing.length > 0 ? JSON.stringify(existing) : "[]";
|
|
38
|
+
return [
|
|
39
|
+
`Project root: ${projectRoot}`,
|
|
40
|
+
"",
|
|
41
|
+
"<relevant_existing_knowledge>", related, "</relevant_existing_knowledge>",
|
|
42
|
+
"",
|
|
43
|
+
"<prior_validation_context>", context, "</prior_validation_context>",
|
|
44
|
+
"",
|
|
45
|
+
"<conversation_delta>", delta, "</conversation_delta>",
|
|
46
|
+
].join("\n");
|
|
20
47
|
}
|
|
21
48
|
|
|
22
49
|
export function parseCandidateEnvelope(text: string): unknown[] {
|
|
@@ -25,9 +52,58 @@ export function parseCandidateEnvelope(text: string): unknown[] {
|
|
|
25
52
|
const start = unfenced.indexOf("{");
|
|
26
53
|
const end = unfenced.lastIndexOf("}");
|
|
27
54
|
if (start < 0 || end <= start) throw new Error("Knowledge reviewer did not return a JSON object");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
let strictError: unknown;
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(unfenced.slice(start, end + 1)) as { candidates?: unknown };
|
|
58
|
+
if (!Array.isArray(parsed.candidates)) throw new Error("Knowledge reviewer response is missing candidates[]");
|
|
59
|
+
return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
strictError = error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const arrayKey = /["']candidates["']\s*:/gu.exec(unfenced);
|
|
65
|
+
const arrayStart = arrayKey ? unfenced.indexOf("[", arrayKey.index + arrayKey[0].length) : -1;
|
|
66
|
+
if (arrayStart >= 0) {
|
|
67
|
+
const candidates: unknown[] = [];
|
|
68
|
+
let inString = false;
|
|
69
|
+
let escaped = false;
|
|
70
|
+
for (let index = arrayStart + 1; index < unfenced.length && candidates.length < KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates; index++) {
|
|
71
|
+
const character = unfenced[index];
|
|
72
|
+
if (inString) {
|
|
73
|
+
if (escaped) escaped = false;
|
|
74
|
+
else if (character === "\\") escaped = true;
|
|
75
|
+
else if (character === '"') inString = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (character === '"') { inString = true; continue; }
|
|
79
|
+
if (character !== "{") continue;
|
|
80
|
+
const objectEnd = balancedObjectEnd(unfenced, index);
|
|
81
|
+
if (objectEnd < 0) break;
|
|
82
|
+
try { candidates.push(JSON.parse(unfenced.slice(index, objectEnd + 1))); } catch { /* Salvage other complete candidates. */ }
|
|
83
|
+
index = objectEnd;
|
|
84
|
+
}
|
|
85
|
+
if (candidates.length > 0) return candidates;
|
|
86
|
+
}
|
|
87
|
+
throw strictError;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function balancedObjectEnd(value: string, start: number): number {
|
|
91
|
+
let depth = 0;
|
|
92
|
+
let inString = false;
|
|
93
|
+
let escaped = false;
|
|
94
|
+
for (let index = start; index < value.length; index++) {
|
|
95
|
+
const character = value[index];
|
|
96
|
+
if (inString) {
|
|
97
|
+
if (escaped) escaped = false;
|
|
98
|
+
else if (character === "\\") escaped = true;
|
|
99
|
+
else if (character === '"') inString = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (character === '"') { inString = true; continue; }
|
|
103
|
+
if (character === "{") depth++;
|
|
104
|
+
else if (character === "}" && --depth === 0) return index;
|
|
105
|
+
}
|
|
106
|
+
return -1;
|
|
31
107
|
}
|
|
32
108
|
|
|
33
109
|
export function knowledgeDeltaDigest(delta: string): string {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { userRuntimePaths } from "../runtime/paths.ts";
|
|
5
|
+
import type { PersistKnowledgeResult } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export interface KnowledgeReviewStatus {
|
|
8
|
+
version: 1;
|
|
9
|
+
protocolVersion: 3;
|
|
10
|
+
state: "leader" | "stepped-down";
|
|
11
|
+
pid: number;
|
|
12
|
+
leaderToken: string;
|
|
13
|
+
electedAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
stepDownReason?: string;
|
|
16
|
+
lastScanAt?: string;
|
|
17
|
+
lastScanError?: { at: string; error: string };
|
|
18
|
+
activeReview?: {
|
|
19
|
+
reviewKey: string;
|
|
20
|
+
sessionKey: string;
|
|
21
|
+
sessionFileHash: string;
|
|
22
|
+
projectKey: string;
|
|
23
|
+
attempt: number;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
};
|
|
26
|
+
lastReview?: {
|
|
27
|
+
reviewKey: string;
|
|
28
|
+
sessionKey: string;
|
|
29
|
+
attempt: number;
|
|
30
|
+
outcome: "saved" | "failed";
|
|
31
|
+
completedAt: string;
|
|
32
|
+
durationMs: number;
|
|
33
|
+
generationId?: string;
|
|
34
|
+
result?: PersistKnowledgeResult;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeKnowledgeReviewStatus(status: KnowledgeReviewStatus, home: string): void {
|
|
40
|
+
const path = userRuntimePaths(home).knowledgeStatus;
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
42
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
43
|
+
writeFileSync(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
44
|
+
renameSync(temporary, path);
|
|
45
|
+
chmodSync(path, 0o600);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readKnowledgeReviewStatus(home: string): KnowledgeReviewStatus | undefined {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(userRuntimePaths(home).knowledgeStatus, "utf8")) as KnowledgeReviewStatus;
|
|
51
|
+
return parsed.version === 1 && parsed.protocolVersion === 3 ? parsed : undefined;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -7,9 +7,11 @@ 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 { parseCandidateEnvelope } from "./extractor.ts";
|
|
10
|
+
import { type KnowledgeReviewStatus, writeKnowledgeReviewStatus } from "./review-status.ts";
|
|
11
|
+
import { sanitizeKnowledgeText } from "./sanitize.ts";
|
|
10
12
|
import { findNextReviewTask } from "./session-scanner.ts";
|
|
11
13
|
import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.ts";
|
|
12
|
-
import type { KnowledgeReviewTask } from "./types.ts";
|
|
14
|
+
import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
|
|
13
15
|
import type { CoordinatorBroadcast, KnowledgeWorkerInput, KnowledgeWorkerOutput } from "./worker-protocol.ts";
|
|
14
16
|
|
|
15
17
|
if (!parentPort) throw new Error("Knowledge review worker requires a parent port");
|
|
@@ -25,6 +27,11 @@ let leaderToken = "";
|
|
|
25
27
|
let standbySocket: Socket | undefined;
|
|
26
28
|
let electionPending = false;
|
|
27
29
|
let activeTask: KnowledgeReviewTask | undefined;
|
|
30
|
+
let activeAttempt = 0;
|
|
31
|
+
let activeStartedAt = 0;
|
|
32
|
+
let activeDeadlineTimer: NodeJS.Timeout | undefined;
|
|
33
|
+
let reviewStatus: KnowledgeReviewStatus | undefined;
|
|
34
|
+
const reviewAttempts = new Map<string, number>();
|
|
28
35
|
|
|
29
36
|
function send(message: KnowledgeWorkerOutput): void {
|
|
30
37
|
parentPort!.postMessage(message);
|
|
@@ -36,13 +43,76 @@ function coordinatorPath(): string {
|
|
|
36
43
|
: paths.knowledgeCoordinatorSocket;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
function writeLeaderMetadata(): void {
|
|
46
|
+
function writeLeaderMetadata(electedAt: string): void {
|
|
40
47
|
writeFileSync(paths.knowledgeLeader, `${JSON.stringify({
|
|
41
|
-
protocolVersion: 3, leaderToken, pid: process.pid, electedAt
|
|
48
|
+
protocolVersion: 3, leaderToken, pid: process.pid, electedAt,
|
|
42
49
|
}, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
43
50
|
chmodSync(paths.knowledgeLeader, 0o600);
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
function publishReviewStatus(): void {
|
|
54
|
+
if (!reviewStatus) return;
|
|
55
|
+
reviewStatus.updatedAt = new Date().toISOString();
|
|
56
|
+
try { writeKnowledgeReviewStatus(reviewStatus, home); } catch (error) {
|
|
57
|
+
send({ type: "review_failed", error: `Unable to write knowledge status: ${error instanceof Error ? error.message : String(error)}` });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function boundedError(error: unknown): string {
|
|
62
|
+
return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error)).slice(0, 1_000);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function beginReview(task: KnowledgeReviewTask): void {
|
|
66
|
+
activeAttempt = (reviewAttempts.get(task.reviewKey) ?? 0) + 1;
|
|
67
|
+
reviewAttempts.set(task.reviewKey, activeAttempt);
|
|
68
|
+
activeStartedAt = Date.now();
|
|
69
|
+
clearTimeout(activeDeadlineTimer);
|
|
70
|
+
activeDeadlineTimer = setTimeout(() => {
|
|
71
|
+
if (activeTask?.requestId !== task.requestId) return;
|
|
72
|
+
const error = "knowledge-review-result-timeout";
|
|
73
|
+
finishReview(task, "failed", { error });
|
|
74
|
+
activeTask = undefined;
|
|
75
|
+
send({ type: "review_cancel", requestId: task.requestId, reason: error });
|
|
76
|
+
send({ type: "review_failed", requestId: task.requestId, error });
|
|
77
|
+
}, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000);
|
|
78
|
+
activeDeadlineTimer.unref();
|
|
79
|
+
if (reviewStatus) {
|
|
80
|
+
reviewStatus.activeReview = {
|
|
81
|
+
reviewKey: task.reviewKey,
|
|
82
|
+
sessionKey: task.sessionKey,
|
|
83
|
+
sessionFileHash: task.sessionFileHash,
|
|
84
|
+
projectKey: task.projectKey,
|
|
85
|
+
attempt: activeAttempt,
|
|
86
|
+
startedAt: new Date(activeStartedAt).toISOString(),
|
|
87
|
+
};
|
|
88
|
+
publishReviewStatus();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function finishReview(
|
|
93
|
+
task: KnowledgeReviewTask,
|
|
94
|
+
outcome: "saved" | "failed",
|
|
95
|
+
detail: { generationId?: string; result?: PersistKnowledgeResult; error?: unknown },
|
|
96
|
+
): void {
|
|
97
|
+
clearTimeout(activeDeadlineTimer);
|
|
98
|
+
activeDeadlineTimer = undefined;
|
|
99
|
+
if (!reviewStatus) return;
|
|
100
|
+
reviewStatus.activeReview = undefined;
|
|
101
|
+
reviewStatus.lastReview = {
|
|
102
|
+
reviewKey: task.reviewKey,
|
|
103
|
+
sessionKey: task.sessionKey,
|
|
104
|
+
attempt: activeAttempt,
|
|
105
|
+
outcome,
|
|
106
|
+
completedAt: new Date().toISOString(),
|
|
107
|
+
durationMs: Math.max(0, Date.now() - activeStartedAt),
|
|
108
|
+
generationId: detail.generationId,
|
|
109
|
+
result: detail.result,
|
|
110
|
+
error: detail.error === undefined ? undefined : boundedError(detail.error),
|
|
111
|
+
};
|
|
112
|
+
if (outcome === "saved") reviewAttempts.delete(task.reviewKey);
|
|
113
|
+
publishReviewStatus();
|
|
114
|
+
}
|
|
115
|
+
|
|
46
116
|
function removeLeaderMetadata(token: string): void {
|
|
47
117
|
try {
|
|
48
118
|
const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8")) as { leaderToken?: string };
|
|
@@ -69,7 +139,17 @@ function scheduleElection(delay = Math.floor(Math.random() * 500)): void {
|
|
|
69
139
|
|
|
70
140
|
function stepDown(reason: string): void {
|
|
71
141
|
if (activeTask) send({ type: "review_cancel", requestId: activeTask.requestId, reason });
|
|
142
|
+
if (reviewStatus) {
|
|
143
|
+
reviewStatus.state = "stepped-down";
|
|
144
|
+
reviewStatus.stepDownReason = reason;
|
|
145
|
+
reviewStatus.activeReview = undefined;
|
|
146
|
+
publishReviewStatus();
|
|
147
|
+
}
|
|
72
148
|
activeTask = undefined;
|
|
149
|
+
clearTimeout(activeDeadlineTimer);
|
|
150
|
+
activeDeadlineTimer = undefined;
|
|
151
|
+
activeAttempt = 0;
|
|
152
|
+
activeStartedAt = 0;
|
|
73
153
|
const token = leaderToken;
|
|
74
154
|
leaderToken = "";
|
|
75
155
|
for (const socket of standbySockets) socket.destroy();
|
|
@@ -83,6 +163,7 @@ function stepDown(reason: string): void {
|
|
|
83
163
|
}
|
|
84
164
|
}
|
|
85
165
|
if (token) removeLeaderMetadata(token);
|
|
166
|
+
reviewStatus = undefined;
|
|
86
167
|
}
|
|
87
168
|
|
|
88
169
|
function becomeStandby(socket: Socket): void {
|
|
@@ -147,6 +228,7 @@ function connectToLeader(probe = 0): void {
|
|
|
147
228
|
function becomeLeader(server: Server): void {
|
|
148
229
|
leaderServer = server;
|
|
149
230
|
leaderToken = randomUUID();
|
|
231
|
+
const electedAt = new Date().toISOString();
|
|
150
232
|
server.unref();
|
|
151
233
|
server.on("connection", (socket) => {
|
|
152
234
|
standbySockets.add(socket);
|
|
@@ -160,7 +242,11 @@ function becomeLeader(server: Server): void {
|
|
|
160
242
|
stepDown("coordinator-server-failed");
|
|
161
243
|
scheduleElection();
|
|
162
244
|
});
|
|
163
|
-
writeLeaderMetadata();
|
|
245
|
+
writeLeaderMetadata(electedAt);
|
|
246
|
+
reviewStatus = {
|
|
247
|
+
version: 1, protocolVersion: 3, state: "leader", pid: process.pid, leaderToken, electedAt, updatedAt: electedAt,
|
|
248
|
+
};
|
|
249
|
+
publishReviewStatus();
|
|
164
250
|
send({ type: "leadership", state: "leader", leaderToken });
|
|
165
251
|
void scanForReview();
|
|
166
252
|
}
|
|
@@ -186,14 +272,21 @@ function attemptElection(): void {
|
|
|
186
272
|
|
|
187
273
|
async function scanForReview(): Promise<void> {
|
|
188
274
|
if (!leaderServer || !leaderToken || activeTask || !sessionsRoot || !eligible) return;
|
|
275
|
+
if (reviewStatus) {
|
|
276
|
+
reviewStatus.lastScanAt = new Date().toISOString();
|
|
277
|
+
reviewStatus.lastScanError = undefined;
|
|
278
|
+
publishReviewStatus();
|
|
279
|
+
}
|
|
189
280
|
try {
|
|
190
281
|
const manifest = loadCurrentManifest(home);
|
|
191
282
|
const task = findNextReviewTask(sessionsRoot, manifest.reviews);
|
|
192
283
|
if (!task) return;
|
|
193
284
|
activeTask = task;
|
|
285
|
+
beginReview(task);
|
|
194
286
|
if (!task.delta) {
|
|
195
287
|
const result = commitKnowledgeReview([], task, leaderToken, home);
|
|
196
288
|
const generationId = loadCurrentManifest(home).generationId;
|
|
289
|
+
finishReview(task, "saved", { generationId, result });
|
|
197
290
|
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
198
291
|
if (generationId) {
|
|
199
292
|
send({ type: "generation_changed", generationId });
|
|
@@ -205,8 +298,13 @@ async function scanForReview(): Promise<void> {
|
|
|
205
298
|
}
|
|
206
299
|
send({ type: "review_request", leaderToken, requestId: task.requestId, task });
|
|
207
300
|
} catch (error) {
|
|
301
|
+
if (activeTask) finishReview(activeTask, "failed", { error });
|
|
302
|
+
else if (reviewStatus) {
|
|
303
|
+
reviewStatus.lastScanError = { at: new Date().toISOString(), error: boundedError(error) };
|
|
304
|
+
publishReviewStatus();
|
|
305
|
+
}
|
|
208
306
|
activeTask = undefined;
|
|
209
|
-
send({ type: "review_failed", error:
|
|
307
|
+
send({ type: "review_failed", error: boundedError(error) });
|
|
210
308
|
}
|
|
211
309
|
}
|
|
212
310
|
|
|
@@ -218,6 +316,7 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
|
|
|
218
316
|
if (message.error || message.raw === undefined) throw new Error(message.error || "Knowledge review returned no content");
|
|
219
317
|
const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
|
|
220
318
|
const generationId = loadCurrentManifest(home).generationId;
|
|
319
|
+
finishReview(task, "saved", { generationId, result });
|
|
221
320
|
send({ type: "review_saved", requestId: task.requestId, generationId, result });
|
|
222
321
|
if (generationId) {
|
|
223
322
|
send({ type: "generation_changed", generationId });
|
|
@@ -225,7 +324,8 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
|
|
|
225
324
|
}
|
|
226
325
|
committed = true;
|
|
227
326
|
} catch (error) {
|
|
228
|
-
|
|
327
|
+
finishReview(task, "failed", { error });
|
|
328
|
+
send({ type: "review_failed", requestId: task.requestId, error: boundedError(error) });
|
|
229
329
|
} finally {
|
|
230
330
|
activeTask = undefined;
|
|
231
331
|
if (committed) queueMicrotask(() => void scanForReview());
|
|
@@ -24,3 +24,23 @@ export function compactKnowledgeText(value: string, maxChars: number): string {
|
|
|
24
24
|
.trim()
|
|
25
25
|
.slice(0, maxChars);
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
export function compactKnowledgeSummary(value: string, maxChars: number): string {
|
|
29
|
+
const normalized = sanitizeKnowledgeText(value).replace(/\s+/gu, " ").trim();
|
|
30
|
+
if (normalized.length <= maxChars || maxChars <= 0) return normalized.slice(0, Math.max(0, maxChars));
|
|
31
|
+
if (maxChars === 1) return "…";
|
|
32
|
+
const limit = maxChars - 1;
|
|
33
|
+
const prefix = normalized.slice(0, limit);
|
|
34
|
+
const minimumBoundary = Math.floor(limit * 0.6);
|
|
35
|
+
const sentenceEnd = Math.max(
|
|
36
|
+
prefix.lastIndexOf("。"), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"),
|
|
37
|
+
prefix.lastIndexOf("."), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"),
|
|
38
|
+
);
|
|
39
|
+
if (sentenceEnd >= minimumBoundary) return prefix.slice(0, sentenceEnd + 1).trimEnd();
|
|
40
|
+
const next = normalized.charAt(limit);
|
|
41
|
+
if (/[A-Za-z0-9]/u.test(prefix.at(-1) ?? "") && /[A-Za-z0-9]/u.test(next)) {
|
|
42
|
+
const wordBoundary = Math.max(prefix.lastIndexOf(" "), prefix.lastIndexOf("-"), prefix.lastIndexOf("/"));
|
|
43
|
+
if (wordBoundary >= minimumBoundary) return `${prefix.slice(0, wordBoundary).trimEnd()}…`;
|
|
44
|
+
}
|
|
45
|
+
return `${prefix.trimEnd()}…`;
|
|
46
|
+
}
|
|
@@ -19,6 +19,9 @@ interface ReviewPiece {
|
|
|
19
19
|
text: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
|
|
23
|
+
const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
|
|
24
|
+
|
|
22
25
|
function hash(value: string, length = 64): string {
|
|
23
26
|
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
24
27
|
}
|
|
@@ -35,20 +38,21 @@ function textContent(content: unknown): string {
|
|
|
35
38
|
|
|
36
39
|
function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefined {
|
|
37
40
|
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
38
|
-
return { index, priority:
|
|
41
|
+
return { index, priority: 2, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
|
|
39
42
|
}
|
|
40
43
|
if (entry.type === "custom" && entry.customType.startsWith("hwcode-workflow")) {
|
|
41
44
|
return {
|
|
42
|
-
index, priority:
|
|
45
|
+
index, priority: 1, category: "workflow",
|
|
43
46
|
text: `[workflow_state:${entry.customType}]\n${sanitizeKnowledgeText(JSON.stringify(entry.data ?? {})).slice(0, 6_000)}`,
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
|
|
47
50
|
const message = entry.message as unknown as Record<string, unknown>;
|
|
48
51
|
const role = typeof message.role === "string" ? message.role : "message";
|
|
49
|
-
const content = sanitizeKnowledgeText(textContent(message.content));
|
|
52
|
+
const content = sanitizeKnowledgeText(textContent(message.content).replace(INJECTED_SKILL_RE, "[loaded skill omitted]"));
|
|
50
53
|
if (!content) return undefined;
|
|
51
|
-
const priority = role === "user" ? 0 : role === "toolResult"
|
|
54
|
+
const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
|
|
55
|
+
: role === "toolResult" ? 4 : 3;
|
|
52
56
|
const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
|
|
53
57
|
const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
|
|
54
58
|
return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
|
|
@@ -61,14 +65,43 @@ function buildDelta(entries: SessionEntry[]): string {
|
|
|
61
65
|
user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
|
|
62
66
|
};
|
|
63
67
|
let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars;
|
|
64
|
-
for (const piece of pieces.slice().sort((left, right) =>
|
|
68
|
+
for (const piece of pieces.slice().sort((left, right) => {
|
|
69
|
+
if (left.priority !== right.priority) return left.priority - right.priority;
|
|
70
|
+
if (left.category === right.category && left.category !== "user") return right.index - left.index;
|
|
71
|
+
return left.index - right.index;
|
|
72
|
+
})) {
|
|
65
73
|
if (remaining <= 0) break;
|
|
66
|
-
const
|
|
74
|
+
const separatorCharacters = selected.length > 0 ? 2 : 0;
|
|
75
|
+
if (remaining <= separatorCharacters) break;
|
|
76
|
+
const text = piece.text.slice(0, Math.min(remaining - separatorCharacters, categoryRemaining[piece.category]));
|
|
67
77
|
if (text) selected.push({ ...piece, text });
|
|
68
|
-
remaining -= text.length;
|
|
78
|
+
remaining -= text.length + separatorCharacters;
|
|
69
79
|
categoryRemaining[piece.category] -= text.length;
|
|
70
80
|
}
|
|
71
|
-
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
|
|
81
|
+
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
|
|
82
|
+
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildValidationContext(entries: SessionEntry[]): string {
|
|
86
|
+
const pieces = entries.map(reviewPiece).filter((piece): piece is ReviewPiece => Boolean(piece));
|
|
87
|
+
const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
|
|
88
|
+
const evidence = pieces.filter((piece) => (
|
|
89
|
+
(piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
|
|
90
|
+
&& FAILURE_EVIDENCE_RE.test(piece.text)
|
|
91
|
+
));
|
|
92
|
+
if (latestWorkflow && !evidence.includes(latestWorkflow)) evidence.push(latestWorkflow);
|
|
93
|
+
let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars;
|
|
94
|
+
const selected: ReviewPiece[] = [];
|
|
95
|
+
for (const piece of evidence.slice().sort((left, right) => right.index - left.index)) {
|
|
96
|
+
if (remaining <= 0) break;
|
|
97
|
+
const separatorCharacters = selected.length > 0 ? 2 : 0;
|
|
98
|
+
if (remaining <= separatorCharacters) break;
|
|
99
|
+
const text = piece.text.slice(0, remaining - separatorCharacters);
|
|
100
|
+
if (text) selected.push({ ...piece, text });
|
|
101
|
+
remaining -= text.length + separatorCharacters;
|
|
102
|
+
}
|
|
103
|
+
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
|
|
104
|
+
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
|
|
72
105
|
}
|
|
73
106
|
|
|
74
107
|
function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
|
|
@@ -120,6 +153,7 @@ export function readReviewTask(
|
|
|
120
153
|
const pending = branch.slice(cursorIndex + 1);
|
|
121
154
|
if (pending.length === 0) return undefined;
|
|
122
155
|
const delta = buildDelta(pending);
|
|
156
|
+
const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
|
|
123
157
|
const firstEntryId = pending[0].id;
|
|
124
158
|
const lastEntryId = pending[pending.length - 1].id;
|
|
125
159
|
const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
|
|
@@ -130,7 +164,7 @@ export function readReviewTask(
|
|
|
130
164
|
return {
|
|
131
165
|
requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
|
|
132
166
|
projectRoot, projectKey: projectKnowledgeKey(projectRoot),
|
|
133
|
-
firstEntryId, lastEntryId, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
167
|
+
firstEntryId, lastEntryId, context, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
134
168
|
};
|
|
135
169
|
}
|
|
136
170
|
|