@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.
@@ -7,13 +7,26 @@ Persist only knowledge that is likely to be useful in future sessions:
7
7
  - verified engineering rules, successful procedures, or expensive failed approaches;
8
8
  - reusable architecture, testing, debugging, deployment, or operational knowledge.
9
9
  Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
10
- Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
11
- Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
10
+ 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.
11
+ 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.
12
+ 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.
13
+ 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.
12
14
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
13
- Return: {"candidates":[{"key":"stable semantic key","title":"...","summary":"...","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false}]}
15
+ 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.
16
+ 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.
17
+ 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"}]}
14
18
  Return {"candidates":[]} when nothing meets the threshold.`;
15
- export function buildKnowledgeExtractionPrompt(projectRoot, delta) {
16
- return `Project root: ${projectRoot}\n\n<conversation_delta>\n${delta}\n</conversation_delta>`;
19
+ export function buildKnowledgeExtractionPrompt(projectRoot, delta, context = "", existing = []) {
20
+ const related = existing.length > 0 ? JSON.stringify(existing) : "[]";
21
+ return [
22
+ `Project root: ${projectRoot}`,
23
+ "",
24
+ "<relevant_existing_knowledge>", related, "</relevant_existing_knowledge>",
25
+ "",
26
+ "<prior_validation_context>", context, "</prior_validation_context>",
27
+ "",
28
+ "<conversation_delta>", delta, "</conversation_delta>",
29
+ ].join("\n");
17
30
  }
18
31
  export function parseCandidateEnvelope(text) {
19
32
  const trimmed = text.trim();
@@ -22,10 +35,78 @@ export function parseCandidateEnvelope(text) {
22
35
  const end = unfenced.lastIndexOf("}");
23
36
  if (start < 0 || end <= start)
24
37
  throw new Error("Knowledge reviewer did not return a JSON object");
25
- const parsed = JSON.parse(unfenced.slice(start, end + 1));
26
- if (!Array.isArray(parsed.candidates))
27
- throw new Error("Knowledge reviewer response is missing candidates[]");
28
- return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
38
+ let strictError;
39
+ try {
40
+ const parsed = JSON.parse(unfenced.slice(start, end + 1));
41
+ if (!Array.isArray(parsed.candidates))
42
+ throw new Error("Knowledge reviewer response is missing candidates[]");
43
+ return parsed.candidates.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates);
44
+ }
45
+ catch (error) {
46
+ strictError = error;
47
+ }
48
+ const arrayKey = /["']candidates["']\s*:/gu.exec(unfenced);
49
+ const arrayStart = arrayKey ? unfenced.indexOf("[", arrayKey.index + arrayKey[0].length) : -1;
50
+ if (arrayStart >= 0) {
51
+ const candidates = [];
52
+ let inString = false;
53
+ let escaped = false;
54
+ for (let index = arrayStart + 1; index < unfenced.length && candidates.length < KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates; index++) {
55
+ const character = unfenced[index];
56
+ if (inString) {
57
+ if (escaped)
58
+ escaped = false;
59
+ else if (character === "\\")
60
+ escaped = true;
61
+ else if (character === '"')
62
+ inString = false;
63
+ continue;
64
+ }
65
+ if (character === '"') {
66
+ inString = true;
67
+ continue;
68
+ }
69
+ if (character !== "{")
70
+ continue;
71
+ const objectEnd = balancedObjectEnd(unfenced, index);
72
+ if (objectEnd < 0)
73
+ break;
74
+ try {
75
+ candidates.push(JSON.parse(unfenced.slice(index, objectEnd + 1)));
76
+ }
77
+ catch { /* Salvage other complete candidates. */ }
78
+ index = objectEnd;
79
+ }
80
+ if (candidates.length > 0)
81
+ return candidates;
82
+ }
83
+ throw strictError;
84
+ }
85
+ function balancedObjectEnd(value, start) {
86
+ let depth = 0;
87
+ let inString = false;
88
+ let escaped = false;
89
+ for (let index = start; index < value.length; index++) {
90
+ const character = value[index];
91
+ if (inString) {
92
+ if (escaped)
93
+ escaped = false;
94
+ else if (character === "\\")
95
+ escaped = true;
96
+ else if (character === '"')
97
+ inString = false;
98
+ continue;
99
+ }
100
+ if (character === '"') {
101
+ inString = true;
102
+ continue;
103
+ }
104
+ if (character === "{")
105
+ depth++;
106
+ else if (character === "}" && --depth === 0)
107
+ return index;
108
+ }
109
+ return -1;
29
110
  }
30
111
  export function knowledgeDeltaDigest(delta) {
31
112
  return createHash("sha256").update(delta).digest("hex");
@@ -0,0 +1,20 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { userRuntimePaths } from "../runtime/paths.js";
4
+ export function writeKnowledgeReviewStatus(status, home) {
5
+ const path = userRuntimePaths(home).knowledgeStatus;
6
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
7
+ const temporary = `${path}.${process.pid}.tmp`;
8
+ writeFileSync(temporary, `${JSON.stringify(status, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
9
+ renameSync(temporary, path);
10
+ chmodSync(path, 0o600);
11
+ }
12
+ export function readKnowledgeReviewStatus(home) {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(userRuntimePaths(home).knowledgeStatus, "utf8"));
15
+ return parsed.version === 1 && parsed.protocolVersion === 3 ? parsed : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
@@ -6,6 +6,8 @@ import { parentPort, workerData } from "node:worker_threads";
6
6
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
7
7
  import { userRuntimePaths } from "../runtime/paths.js";
8
8
  import { parseCandidateEnvelope } from "./extractor.js";
9
+ import { writeKnowledgeReviewStatus } from "./review-status.js";
10
+ import { sanitizeKnowledgeText } from "./sanitize.js";
9
11
  import { findNextReviewTask } from "./session-scanner.js";
10
12
  import { commitKnowledgeReview, ensureKnowledgeDirectories, loadCurrentManifest } from "./store.js";
11
13
  if (!parentPort)
@@ -21,6 +23,11 @@ let leaderToken = "";
21
23
  let standbySocket;
22
24
  let electionPending = false;
23
25
  let activeTask;
26
+ let activeAttempt = 0;
27
+ let activeStartedAt = 0;
28
+ let activeDeadlineTimer;
29
+ let reviewStatus;
30
+ const reviewAttempts = new Map();
24
31
  function send(message) {
25
32
  parentPort.postMessage(message);
26
33
  }
@@ -29,12 +36,74 @@ function coordinatorPath() {
29
36
  ? `\\\\.\\pipe\\hwcode-knowledge-v3-${Buffer.from(home).toString("hex").slice(0, 16)}`
30
37
  : paths.knowledgeCoordinatorSocket;
31
38
  }
32
- function writeLeaderMetadata() {
39
+ function writeLeaderMetadata(electedAt) {
33
40
  writeFileSync(paths.knowledgeLeader, `${JSON.stringify({
34
- protocolVersion: 3, leaderToken, pid: process.pid, electedAt: new Date().toISOString(),
41
+ protocolVersion: 3, leaderToken, pid: process.pid, electedAt,
35
42
  }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
36
43
  chmodSync(paths.knowledgeLeader, 0o600);
37
44
  }
45
+ function publishReviewStatus() {
46
+ if (!reviewStatus)
47
+ return;
48
+ reviewStatus.updatedAt = new Date().toISOString();
49
+ try {
50
+ writeKnowledgeReviewStatus(reviewStatus, home);
51
+ }
52
+ catch (error) {
53
+ send({ type: "review_failed", error: `Unable to write knowledge status: ${error instanceof Error ? error.message : String(error)}` });
54
+ }
55
+ }
56
+ function boundedError(error) {
57
+ return sanitizeKnowledgeText(error instanceof Error ? error.message : String(error)).slice(0, 1_000);
58
+ }
59
+ function beginReview(task) {
60
+ activeAttempt = (reviewAttempts.get(task.reviewKey) ?? 0) + 1;
61
+ reviewAttempts.set(task.reviewKey, activeAttempt);
62
+ activeStartedAt = Date.now();
63
+ clearTimeout(activeDeadlineTimer);
64
+ activeDeadlineTimer = setTimeout(() => {
65
+ if (activeTask?.requestId !== task.requestId)
66
+ return;
67
+ const error = "knowledge-review-result-timeout";
68
+ finishReview(task, "failed", { error });
69
+ activeTask = undefined;
70
+ send({ type: "review_cancel", requestId: task.requestId, reason: error });
71
+ send({ type: "review_failed", requestId: task.requestId, error });
72
+ }, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs + 5_000);
73
+ activeDeadlineTimer.unref();
74
+ if (reviewStatus) {
75
+ reviewStatus.activeReview = {
76
+ reviewKey: task.reviewKey,
77
+ sessionKey: task.sessionKey,
78
+ sessionFileHash: task.sessionFileHash,
79
+ projectKey: task.projectKey,
80
+ attempt: activeAttempt,
81
+ startedAt: new Date(activeStartedAt).toISOString(),
82
+ };
83
+ publishReviewStatus();
84
+ }
85
+ }
86
+ function finishReview(task, outcome, detail) {
87
+ clearTimeout(activeDeadlineTimer);
88
+ activeDeadlineTimer = undefined;
89
+ if (!reviewStatus)
90
+ return;
91
+ reviewStatus.activeReview = undefined;
92
+ reviewStatus.lastReview = {
93
+ reviewKey: task.reviewKey,
94
+ sessionKey: task.sessionKey,
95
+ attempt: activeAttempt,
96
+ outcome,
97
+ completedAt: new Date().toISOString(),
98
+ durationMs: Math.max(0, Date.now() - activeStartedAt),
99
+ generationId: detail.generationId,
100
+ result: detail.result,
101
+ error: detail.error === undefined ? undefined : boundedError(detail.error),
102
+ };
103
+ if (outcome === "saved")
104
+ reviewAttempts.delete(task.reviewKey);
105
+ publishReviewStatus();
106
+ }
38
107
  function removeLeaderMetadata(token) {
39
108
  try {
40
109
  const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8"));
@@ -63,7 +132,17 @@ function scheduleElection(delay = Math.floor(Math.random() * 500)) {
63
132
  function stepDown(reason) {
64
133
  if (activeTask)
65
134
  send({ type: "review_cancel", requestId: activeTask.requestId, reason });
135
+ if (reviewStatus) {
136
+ reviewStatus.state = "stepped-down";
137
+ reviewStatus.stepDownReason = reason;
138
+ reviewStatus.activeReview = undefined;
139
+ publishReviewStatus();
140
+ }
66
141
  activeTask = undefined;
142
+ clearTimeout(activeDeadlineTimer);
143
+ activeDeadlineTimer = undefined;
144
+ activeAttempt = 0;
145
+ activeStartedAt = 0;
67
146
  const token = leaderToken;
68
147
  leaderToken = "";
69
148
  for (const socket of standbySockets)
@@ -85,6 +164,7 @@ function stepDown(reason) {
85
164
  }
86
165
  if (token)
87
166
  removeLeaderMetadata(token);
167
+ reviewStatus = undefined;
88
168
  }
89
169
  function becomeStandby(socket) {
90
170
  standbySocket = socket;
@@ -162,6 +242,7 @@ function connectToLeader(probe = 0) {
162
242
  function becomeLeader(server) {
163
243
  leaderServer = server;
164
244
  leaderToken = randomUUID();
245
+ const electedAt = new Date().toISOString();
165
246
  server.unref();
166
247
  server.on("connection", (socket) => {
167
248
  standbySockets.add(socket);
@@ -176,7 +257,11 @@ function becomeLeader(server) {
176
257
  stepDown("coordinator-server-failed");
177
258
  scheduleElection();
178
259
  });
179
- writeLeaderMetadata();
260
+ writeLeaderMetadata(electedAt);
261
+ reviewStatus = {
262
+ version: 1, protocolVersion: 3, state: "leader", pid: process.pid, leaderToken, electedAt, updatedAt: electedAt,
263
+ };
264
+ publishReviewStatus();
180
265
  send({ type: "leadership", state: "leader", leaderToken });
181
266
  void scanForReview();
182
267
  }
@@ -204,15 +289,22 @@ function attemptElection() {
204
289
  async function scanForReview() {
205
290
  if (!leaderServer || !leaderToken || activeTask || !sessionsRoot || !eligible)
206
291
  return;
292
+ if (reviewStatus) {
293
+ reviewStatus.lastScanAt = new Date().toISOString();
294
+ reviewStatus.lastScanError = undefined;
295
+ publishReviewStatus();
296
+ }
207
297
  try {
208
298
  const manifest = loadCurrentManifest(home);
209
299
  const task = findNextReviewTask(sessionsRoot, manifest.reviews);
210
300
  if (!task)
211
301
  return;
212
302
  activeTask = task;
303
+ beginReview(task);
213
304
  if (!task.delta) {
214
305
  const result = commitKnowledgeReview([], task, leaderToken, home);
215
306
  const generationId = loadCurrentManifest(home).generationId;
307
+ finishReview(task, "saved", { generationId, result });
216
308
  send({ type: "review_saved", requestId: task.requestId, generationId, result });
217
309
  if (generationId) {
218
310
  send({ type: "generation_changed", generationId });
@@ -225,8 +317,14 @@ async function scanForReview() {
225
317
  send({ type: "review_request", leaderToken, requestId: task.requestId, task });
226
318
  }
227
319
  catch (error) {
320
+ if (activeTask)
321
+ finishReview(activeTask, "failed", { error });
322
+ else if (reviewStatus) {
323
+ reviewStatus.lastScanError = { at: new Date().toISOString(), error: boundedError(error) };
324
+ publishReviewStatus();
325
+ }
228
326
  activeTask = undefined;
229
- send({ type: "review_failed", error: error instanceof Error ? error.message : String(error) });
327
+ send({ type: "review_failed", error: boundedError(error) });
230
328
  }
231
329
  }
232
330
  function handleReviewResult(message) {
@@ -239,6 +337,7 @@ function handleReviewResult(message) {
239
337
  throw new Error(message.error || "Knowledge review returned no content");
240
338
  const result = commitKnowledgeReview(parseCandidateEnvelope(message.raw), task, leaderToken, home);
241
339
  const generationId = loadCurrentManifest(home).generationId;
340
+ finishReview(task, "saved", { generationId, result });
242
341
  send({ type: "review_saved", requestId: task.requestId, generationId, result });
243
342
  if (generationId) {
244
343
  send({ type: "generation_changed", generationId });
@@ -247,7 +346,8 @@ function handleReviewResult(message) {
247
346
  committed = true;
248
347
  }
249
348
  catch (error) {
250
- send({ type: "review_failed", requestId: task.requestId, error: error instanceof Error ? error.message : String(error) });
349
+ finishReview(task, "failed", { error });
350
+ send({ type: "review_failed", requestId: task.requestId, error: boundedError(error) });
251
351
  }
252
352
  finally {
253
353
  activeTask = undefined;
@@ -22,3 +22,23 @@ export function compactKnowledgeText(value, maxChars) {
22
22
  .trim()
23
23
  .slice(0, maxChars);
24
24
  }
25
+ export function compactKnowledgeSummary(value, maxChars) {
26
+ const normalized = sanitizeKnowledgeText(value).replace(/\s+/gu, " ").trim();
27
+ if (normalized.length <= maxChars || maxChars <= 0)
28
+ return normalized.slice(0, Math.max(0, maxChars));
29
+ if (maxChars === 1)
30
+ return "…";
31
+ const limit = maxChars - 1;
32
+ const prefix = normalized.slice(0, limit);
33
+ const minimumBoundary = Math.floor(limit * 0.6);
34
+ const sentenceEnd = Math.max(prefix.lastIndexOf("。"), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"), prefix.lastIndexOf("."), prefix.lastIndexOf("!"), prefix.lastIndexOf("?"));
35
+ if (sentenceEnd >= minimumBoundary)
36
+ return prefix.slice(0, sentenceEnd + 1).trimEnd();
37
+ const next = normalized.charAt(limit);
38
+ if (/[A-Za-z0-9]/u.test(prefix.at(-1) ?? "") && /[A-Za-z0-9]/u.test(next)) {
39
+ const wordBoundary = Math.max(prefix.lastIndexOf(" "), prefix.lastIndexOf("-"), prefix.lastIndexOf("/"));
40
+ if (wordBoundary >= minimumBoundary)
41
+ return `${prefix.slice(0, wordBoundary).trimEnd()}…`;
42
+ }
43
+ return `${prefix.trimEnd()}…`;
44
+ }
@@ -6,6 +6,8 @@ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
6
6
  import { knowledgeDeltaDigest } from "./extractor.js";
7
7
  import { sanitizeKnowledgeText } from "./sanitize.js";
8
8
  import { projectKnowledgeKey } from "./store.js";
9
+ const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
10
+ const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
9
11
  function hash(value, length = 64) {
10
12
  return createHash("sha256").update(value).digest("hex").slice(0, length);
11
13
  }
@@ -23,11 +25,11 @@ function textContent(content) {
23
25
  }
24
26
  function reviewPiece(entry, index) {
25
27
  if (entry.type === "compaction" || entry.type === "branch_summary") {
26
- return { index, priority: 1, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
28
+ return { index, priority: 2, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
27
29
  }
28
30
  if (entry.type === "custom" && entry.customType.startsWith("hwcode-workflow")) {
29
31
  return {
30
- index, priority: 0, category: "workflow",
32
+ index, priority: 1, category: "workflow",
31
33
  text: `[workflow_state:${entry.customType}]\n${sanitizeKnowledgeText(JSON.stringify(entry.data ?? {})).slice(0, 6_000)}`,
32
34
  };
33
35
  }
@@ -35,10 +37,11 @@ function reviewPiece(entry, index) {
35
37
  return undefined;
36
38
  const message = entry.message;
37
39
  const role = typeof message.role === "string" ? message.role : "message";
38
- const content = sanitizeKnowledgeText(textContent(message.content));
40
+ const content = sanitizeKnowledgeText(textContent(message.content).replace(INJECTED_SKILL_RE, "[loaded skill omitted]"));
39
41
  if (!content)
40
42
  return undefined;
41
- const priority = role === "user" ? 0 : role === "toolResult" ? 3 : 2;
43
+ const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
44
+ : role === "toolResult" ? 4 : 3;
42
45
  const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
43
46
  const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
44
47
  return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
@@ -50,16 +53,49 @@ function buildDelta(entries) {
50
53
  user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
51
54
  };
52
55
  let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars;
53
- for (const piece of pieces.slice().sort((left, right) => left.priority - right.priority || left.index - right.index)) {
56
+ for (const piece of pieces.slice().sort((left, right) => {
57
+ if (left.priority !== right.priority)
58
+ return left.priority - right.priority;
59
+ if (left.category === right.category && left.category !== "user")
60
+ return right.index - left.index;
61
+ return left.index - right.index;
62
+ })) {
54
63
  if (remaining <= 0)
55
64
  break;
56
- const text = piece.text.slice(0, Math.min(remaining, categoryRemaining[piece.category]));
65
+ const separatorCharacters = selected.length > 0 ? 2 : 0;
66
+ if (remaining <= separatorCharacters)
67
+ break;
68
+ const text = piece.text.slice(0, Math.min(remaining - separatorCharacters, categoryRemaining[piece.category]));
57
69
  if (text)
58
70
  selected.push({ ...piece, text });
59
- remaining -= text.length;
71
+ remaining -= text.length + separatorCharacters;
60
72
  categoryRemaining[piece.category] -= text.length;
61
73
  }
62
- return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n");
74
+ return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
75
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
76
+ }
77
+ function buildValidationContext(entries) {
78
+ const pieces = entries.map(reviewPiece).filter((piece) => Boolean(piece));
79
+ const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
80
+ const evidence = pieces.filter((piece) => ((piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
81
+ && FAILURE_EVIDENCE_RE.test(piece.text)));
82
+ if (latestWorkflow && !evidence.includes(latestWorkflow))
83
+ evidence.push(latestWorkflow);
84
+ let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars;
85
+ const selected = [];
86
+ for (const piece of evidence.slice().sort((left, right) => right.index - left.index)) {
87
+ if (remaining <= 0)
88
+ break;
89
+ const separatorCharacters = selected.length > 0 ? 2 : 0;
90
+ if (remaining <= separatorCharacters)
91
+ break;
92
+ const text = piece.text.slice(0, remaining - separatorCharacters);
93
+ if (text)
94
+ selected.push({ ...piece, text });
95
+ remaining -= text.length + separatorCharacters;
96
+ }
97
+ return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
98
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
63
99
  }
64
100
  function projectRootForSession(header, branch) {
65
101
  for (const entry of branch.slice().reverse()) {
@@ -128,6 +164,7 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
128
164
  if (pending.length === 0)
129
165
  return undefined;
130
166
  const delta = buildDelta(pending);
167
+ const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
131
168
  const firstEntryId = pending[0].id;
132
169
  const lastEntryId = pending[pending.length - 1].id;
133
170
  const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
@@ -138,7 +175,7 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
138
175
  return {
139
176
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
140
177
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
141
- firstEntryId, lastEntryId, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
178
+ firstEntryId, lastEntryId, context, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
142
179
  };
143
180
  }
144
181
  export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve, sep } from "node:path";
5
5
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
6
6
  import { userRuntimePaths } from "../runtime/paths.js";
7
- import { compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.js";
7
+ import { compactKnowledgeSummary, compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.js";
8
8
  const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
9
9
  const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
10
10
  const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
@@ -101,6 +101,57 @@ function normalizeSlug(value) {
101
101
  function normalizedFingerprint(candidate) {
102
102
  return createHash("sha256").update(`${candidate.scope}\0${candidate.key.trim().toLowerCase()}`).digest("hex");
103
103
  }
104
+ function comparisonTerms(value) {
105
+ return new Set((value.normalize("NFKC").toLowerCase().match(/\p{Script=Han}+|[\p{L}\p{N}]+/gu) ?? [])
106
+ .filter((term) => term.length >= 2));
107
+ }
108
+ function containment(left, right) {
109
+ const minimum = Math.min(left.size, right.size);
110
+ if (minimum === 0)
111
+ return 0;
112
+ let shared = 0;
113
+ for (const term of left)
114
+ if (right.has(term))
115
+ shared++;
116
+ return shared / minimum;
117
+ }
118
+ function semanticDuplicateScore(candidate, entry) {
119
+ if (candidate.scope !== entry.scope || candidate.storageHint !== entry.track)
120
+ return 0;
121
+ const titleScore = containment(comparisonTerms(candidate.title), comparisonTerms(entry.title));
122
+ const keywordScore = containment(comparisonTerms(candidate.keywords.join(" ")), comparisonTerms(entry.keywords.join(" ")));
123
+ if (titleScore < 0.8 || keywordScore < 0.5)
124
+ return 0;
125
+ return titleScore * 0.7 + keywordScore * 0.3;
126
+ }
127
+ function semanticDuplicateIndex(candidate, catalog) {
128
+ let bestIndex = -1;
129
+ let bestScore = 0;
130
+ for (const [index, entry] of catalog.items.entries()) {
131
+ const score = semanticDuplicateScore(candidate, entry);
132
+ if (score > bestScore) {
133
+ bestIndex = index;
134
+ bestScore = score;
135
+ }
136
+ }
137
+ return bestIndex;
138
+ }
139
+ function unsupportedOperations(value) {
140
+ return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)]
141
+ .map((match) => match[1].toLowerCase());
142
+ }
143
+ function contradictsVerifiedFailures(body, validationText) {
144
+ const normalizedBody = body.toLowerCase();
145
+ for (const operation of unsupportedOperations(validationText)) {
146
+ const index = normalizedBody.search(new RegExp(`\\b${operation}\\b`, "u"));
147
+ if (index < 0)
148
+ continue;
149
+ const context = normalizedBody.slice(Math.max(0, index - 80), index + operation.length + 80);
150
+ if (!/(?:not supported|unsupported|do not|don't|avoid|不支持|不要|避免)/u.test(context))
151
+ return true;
152
+ }
153
+ return false;
154
+ }
104
155
  function contentHash(candidate) {
105
156
  return createHash("sha256").update(`${candidate.title}\0${candidate.summary}\0${candidate.body}`).digest("hex");
106
157
  }
@@ -110,13 +161,15 @@ function asStringArray(value, maxItems) {
110
161
  return [...new Set(value.filter((item) => typeof item === "string")
111
162
  .map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
112
163
  }
113
- function normalizeCandidate(value, projectKey) {
164
+ function normalizeCandidate(value, projectKey, validationText) {
114
165
  if (!value || typeof value !== "object")
115
166
  return undefined;
116
167
  const raw = value;
117
168
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
118
169
  || typeof raw.body !== "string" || typeof raw.confidence !== "number")
119
170
  return undefined;
171
+ if (raw.durability !== "stable")
172
+ return undefined;
120
173
  if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence)
121
174
  return undefined;
122
175
  const explicitUserDirective = raw.explicitUserDirective === true;
@@ -125,17 +178,22 @@ function normalizeCandidate(value, projectKey) {
125
178
  const evidence = asStringArray(raw.evidence, 8);
126
179
  if (!body || evidence.length === 0)
127
180
  return undefined;
181
+ if (contradictsVerifiedFailures(body, validationText))
182
+ return undefined;
128
183
  const requestedTrack = raw.storageHint === "rule" ? "rule" : "topic";
129
184
  const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
185
+ && body.split(/\n\s*\n/gu).length === 1
130
186
  && (explicitUserDirective || raw.confidence >= 0.9);
187
+ const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
131
188
  return {
132
- key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
133
- summary: compactKnowledgeText(raw.summary, STORAGE.maxSummaryChars),
189
+ key: compactKnowledgeText(raw.key, 160), targetId,
190
+ title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
191
+ summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
134
192
  keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
135
193
  scope, body, evidence, confidence: Math.min(1, raw.confidence),
136
194
  storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
137
195
  action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
138
- explicitUserDirective,
196
+ explicitUserDirective, durability: "stable",
139
197
  };
140
198
  }
141
199
  function renderKnowledgeFile(candidate) {
@@ -267,16 +325,30 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
267
325
  const catalog = structuredClone(current.catalog);
268
326
  const result = { saved: 0, updated: 0, pending: 0, skipped: 0 };
269
327
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
270
- const candidate = normalizeCandidate(value, task.projectKey);
328
+ const candidate = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
271
329
  if (!candidate) {
272
330
  result.skipped++;
273
331
  continue;
274
332
  }
275
333
  const fingerprint = normalizedFingerprint(candidate);
276
334
  const hash = contentHash(candidate);
277
- const existingIndex = catalog.items.findIndex((item) => item.fingerprint === fingerprint);
335
+ const targetIndex = candidate.targetId
336
+ ? catalog.items.findIndex((item) => item.id === candidate.targetId && applicable(item, task.projectKey))
337
+ : -1;
338
+ if (candidate.targetId && targetIndex < 0) {
339
+ result.skipped++;
340
+ continue;
341
+ }
342
+ let existingIndex = targetIndex >= 0
343
+ ? targetIndex
344
+ : catalog.items.findIndex((item) => item.fingerprint === fingerprint);
345
+ let semanticReinforcement = false;
346
+ if (existingIndex < 0) {
347
+ existingIndex = semanticDuplicateIndex(candidate, catalog);
348
+ semanticReinforcement = existingIndex >= 0;
349
+ }
278
350
  const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
279
- if (existing?.contentHash === hash) {
351
+ if (existing && (existing.contentHash === hash || candidate.action === "reinforce" || semanticReinforcement)) {
280
352
  existing.evidenceCount += candidate.evidence.length;
281
353
  existing.updatedAt = new Date().toISOString();
282
354
  result.updated++;
@@ -299,8 +371,9 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
299
371
  const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
300
372
  atomicWrite(join(temporary, relativeFile), renderKnowledgeFile(candidate));
301
373
  const entry = {
302
- id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
303
- keywords: candidate.keywords, scope: candidate.scope, track, file: relativeFile,
374
+ id, fingerprint: existing?.fingerprint ?? fingerprint, contentHash: hash,
375
+ title: candidate.title, summary: candidate.summary,
376
+ keywords: candidate.keywords, scope: existing?.scope ?? candidate.scope, track, file: relativeFile,
304
377
  evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
305
378
  createdAt: existing?.createdAt ?? now, updatedAt: now,
306
379
  };
@@ -30,14 +30,18 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
30
30
  intervalMs: 60_000,
31
31
  idleMs: 60_000,
32
32
  capabilityPollMs: 5_000,
33
- modelTimeoutMs: 120_000,
34
- maxDeltaChars: 30_000,
35
- maxCandidates: 8,
33
+ modelTimeoutMs: 180_000,
34
+ maxDeltaChars: 12_000,
35
+ maxPriorContextChars: 4_000,
36
+ maxExistingContextItems: 5,
37
+ maxExistingBodyItems: 2,
38
+ maxExistingBodyChars: 600,
39
+ maxCandidates: 3,
36
40
  minimumConfidence: 0.72,
37
41
  }),
38
42
  storage: Object.freeze({
39
- maxRuleChars: 600,
40
- maxRuleFileLines: 40,
43
+ maxRuleChars: 320,
44
+ maxRuleFileLines: 6,
41
45
  maxRulesPromptChars: 12_000,
42
46
  maxMemoryChars: 25_000,
43
47
  maxMemoryLines: 200,
@@ -40,6 +40,7 @@ export function userRuntimePaths(home = homedir()) {
40
40
  // Unix-domain socket paths are short on purpose (macOS caps them at roughly 104 bytes).
41
41
  knowledgeCoordinatorSocket: join(root, "knowledge-v3.sock"),
42
42
  knowledgeLeader: join(knowledge, "runtime", "leader.json"),
43
+ knowledgeStatus: join(knowledge, "runtime", "status.json"),
43
44
  knowledgeCommitLock: join(knowledge, "runtime", "commit.lock"),
44
45
  knowledgeElectionLock: join(knowledge, "runtime", "election.lock"),
45
46
  };