@hadooppei/hwcode 1.0.11 → 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.
@@ -8,14 +8,25 @@ Persist only knowledge that is likely to be useful in future sessions:
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
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
- Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
12
- Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
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.
13
14
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
14
- Each summary must be one complete sentence of at most 160 characters. Keep each body under 3000 characters. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered.
15
- Return: {"candidates":[{"key":"stable semantic key","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"}]}
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"}]}
16
18
  Return {"candidates":[]} when nothing meets the threshold.`;
17
- export function buildKnowledgeExtractionPrompt(projectRoot, delta) {
18
- 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");
19
30
  }
20
31
  export function parseCandidateEnvelope(text) {
21
32
  const trimmed = text.trim();
@@ -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()) {
@@ -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,7 +161,7 @@ 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;
@@ -127,11 +178,16 @@ function normalizeCandidate(value, projectKey) {
127
178
  const evidence = asStringArray(raw.evidence, 8);
128
179
  if (!body || evidence.length === 0)
129
180
  return undefined;
181
+ if (contradictsVerifiedFailures(body, validationText))
182
+ return undefined;
130
183
  const requestedTrack = raw.storageHint === "rule" ? "rule" : "topic";
131
184
  const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
185
+ && body.split(/\n\s*\n/gu).length === 1
132
186
  && (explicitUserDirective || raw.confidence >= 0.9);
187
+ const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
133
188
  return {
134
- key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
189
+ key: compactKnowledgeText(raw.key, 160), targetId,
190
+ title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
135
191
  summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
136
192
  keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
137
193
  scope, body, evidence, confidence: Math.min(1, raw.confidence),
@@ -269,16 +325,30 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
269
325
  const catalog = structuredClone(current.catalog);
270
326
  const result = { saved: 0, updated: 0, pending: 0, skipped: 0 };
271
327
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
272
- const candidate = normalizeCandidate(value, task.projectKey);
328
+ const candidate = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
273
329
  if (!candidate) {
274
330
  result.skipped++;
275
331
  continue;
276
332
  }
277
333
  const fingerprint = normalizedFingerprint(candidate);
278
334
  const hash = contentHash(candidate);
279
- 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
+ }
280
350
  const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
281
- if (existing?.contentHash === hash) {
351
+ if (existing && (existing.contentHash === hash || candidate.action === "reinforce" || semanticReinforcement)) {
282
352
  existing.evidenceCount += candidate.evidence.length;
283
353
  existing.updatedAt = new Date().toISOString();
284
354
  result.updated++;
@@ -301,8 +371,9 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
301
371
  const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
302
372
  atomicWrite(join(temporary, relativeFile), renderKnowledgeFile(candidate));
303
373
  const entry = {
304
- id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
305
- 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,
306
377
  evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
307
378
  createdAt: existing?.createdAt ?? now, updatedAt: now,
308
379
  };
@@ -31,13 +31,17 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
31
31
  idleMs: 60_000,
32
32
  capabilityPollMs: 5_000,
33
33
  modelTimeoutMs: 180_000,
34
- maxDeltaChars: 20_000,
35
- maxCandidates: 5,
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,
@@ -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";
@@ -103,13 +103,39 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
103
103
  runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
104
104
  timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
105
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
+ });
106
124
  const response = await ctx.modelRegistry.complete(
107
125
  ctx.model,
108
126
  {
109
127
  systemPrompt: KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
110
128
  messages: [{
111
129
  role: "user",
112
- content: [{ type: "text", text: buildKnowledgeExtractionPrompt(message.task.projectRoot, message.task.delta) }],
130
+ content: [{
131
+ type: "text",
132
+ text: buildKnowledgeExtractionPrompt(
133
+ message.task.projectRoot,
134
+ message.task.delta,
135
+ message.task.context,
136
+ related,
137
+ ),
138
+ }],
113
139
  timestamp: Date.now(),
114
140
  }],
115
141
  },
@@ -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:
@@ -10,15 +19,31 @@ Persist only knowledge that is likely to be useful in future sessions:
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
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.
13
- Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
14
- Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
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.
15
25
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
16
- Each summary must be one complete sentence of at most 160 characters. Keep each body under 3000 characters. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered.
17
- Return: {"candidates":[{"key":"stable semantic key","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"}]}
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"}]}
18
29
  Return {"candidates":[]} when nothing meets the threshold.`;
19
30
 
20
- export function buildKnowledgeExtractionPrompt(projectRoot: string, delta: string): string {
21
- return `Project root: ${projectRoot}\n\n<conversation_delta>\n${delta}\n</conversation_delta>`;
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");
22
47
  }
23
48
 
24
49
  export function parseCandidateEnvelope(text: string): unknown[] {
@@ -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: 1, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
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: 0, category: "workflow",
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" ? 3 : 2;
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) => left.priority - right.priority || left.index - right.index)) {
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 text = piece.text.slice(0, Math.min(remaining, categoryRemaining[piece.category]));
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
 
@@ -117,6 +117,56 @@ function normalizedFingerprint(candidate: KnowledgeCandidate): string {
117
117
  return createHash("sha256").update(`${candidate.scope}\0${candidate.key.trim().toLowerCase()}`).digest("hex");
118
118
  }
119
119
 
120
+ function comparisonTerms(value: string): Set<string> {
121
+ return new Set((value.normalize("NFKC").toLowerCase().match(/\p{Script=Han}+|[\p{L}\p{N}]+/gu) ?? [])
122
+ .filter((term) => term.length >= 2));
123
+ }
124
+
125
+ function containment(left: Set<string>, right: Set<string>): number {
126
+ const minimum = Math.min(left.size, right.size);
127
+ if (minimum === 0) return 0;
128
+ let shared = 0;
129
+ for (const term of left) if (right.has(term)) shared++;
130
+ return shared / minimum;
131
+ }
132
+
133
+ function semanticDuplicateScore(candidate: KnowledgeCandidate, entry: KnowledgeCatalogEntry): number {
134
+ if (candidate.scope !== entry.scope || candidate.storageHint !== entry.track) return 0;
135
+ const titleScore = containment(comparisonTerms(candidate.title), comparisonTerms(entry.title));
136
+ const keywordScore = containment(
137
+ comparisonTerms(candidate.keywords.join(" ")),
138
+ comparisonTerms(entry.keywords.join(" ")),
139
+ );
140
+ if (titleScore < 0.8 || keywordScore < 0.5) return 0;
141
+ return titleScore * 0.7 + keywordScore * 0.3;
142
+ }
143
+
144
+ function semanticDuplicateIndex(candidate: KnowledgeCandidate, catalog: KnowledgeCatalog): number {
145
+ let bestIndex = -1;
146
+ let bestScore = 0;
147
+ for (const [index, entry] of catalog.items.entries()) {
148
+ const score = semanticDuplicateScore(candidate, entry);
149
+ if (score > bestScore) { bestIndex = index; bestScore = score; }
150
+ }
151
+ return bestIndex;
152
+ }
153
+
154
+ function unsupportedOperations(value: string): string[] {
155
+ return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)]
156
+ .map((match) => match[1]!.toLowerCase());
157
+ }
158
+
159
+ function contradictsVerifiedFailures(body: string, validationText: string): boolean {
160
+ const normalizedBody = body.toLowerCase();
161
+ for (const operation of unsupportedOperations(validationText)) {
162
+ const index = normalizedBody.search(new RegExp(`\\b${operation}\\b`, "u"));
163
+ if (index < 0) continue;
164
+ const context = normalizedBody.slice(Math.max(0, index - 80), index + operation.length + 80);
165
+ if (!/(?:not supported|unsupported|do not|don't|avoid|不支持|不要|避免)/u.test(context)) return true;
166
+ }
167
+ return false;
168
+ }
169
+
120
170
  function contentHash(candidate: KnowledgeCandidate): string {
121
171
  return createHash("sha256").update(`${candidate.title}\0${candidate.summary}\0${candidate.body}`).digest("hex");
122
172
  }
@@ -127,7 +177,7 @@ function asStringArray(value: unknown, maxItems: number): string[] {
127
177
  .map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
128
178
  }
129
179
 
130
- function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandidate | undefined {
180
+ function normalizeCandidate(value: unknown, projectKey: string, validationText: string): KnowledgeCandidate | undefined {
131
181
  if (!value || typeof value !== "object") return undefined;
132
182
  const raw = value as Record<string, unknown>;
133
183
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
@@ -139,11 +189,15 @@ function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandid
139
189
  const body = compactKnowledgeText(raw.body, 20_000);
140
190
  const evidence = asStringArray(raw.evidence, 8);
141
191
  if (!body || evidence.length === 0) return undefined;
192
+ if (contradictsVerifiedFailures(body, validationText)) return undefined;
142
193
  const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
143
194
  const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
195
+ && body.split(/\n\s*\n/gu).length === 1
144
196
  && (explicitUserDirective || raw.confidence >= 0.9);
197
+ const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
145
198
  return {
146
- key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
199
+ key: compactKnowledgeText(raw.key, 160), targetId,
200
+ title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
147
201
  summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
148
202
  keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
149
203
  scope, body, evidence, confidence: Math.min(1, raw.confidence),
@@ -275,13 +329,24 @@ export function commitKnowledgeReview(
275
329
  const catalog: KnowledgeCatalog = structuredClone(current.catalog);
276
330
  const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
277
331
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
278
- const candidate = normalizeCandidate(value, task.projectKey);
332
+ const candidate = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
279
333
  if (!candidate) { result.skipped++; continue; }
280
334
  const fingerprint = normalizedFingerprint(candidate);
281
335
  const hash = contentHash(candidate);
282
- const existingIndex = catalog.items.findIndex((item) => item.fingerprint === fingerprint);
336
+ const targetIndex = candidate.targetId
337
+ ? catalog.items.findIndex((item) => item.id === candidate.targetId && applicable(item, task.projectKey))
338
+ : -1;
339
+ if (candidate.targetId && targetIndex < 0) { result.skipped++; continue; }
340
+ let existingIndex = targetIndex >= 0
341
+ ? targetIndex
342
+ : catalog.items.findIndex((item) => item.fingerprint === fingerprint);
343
+ let semanticReinforcement = false;
344
+ if (existingIndex < 0) {
345
+ existingIndex = semanticDuplicateIndex(candidate, catalog);
346
+ semanticReinforcement = existingIndex >= 0;
347
+ }
283
348
  const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
284
- if (existing?.contentHash === hash) {
349
+ if (existing && (existing.contentHash === hash || candidate.action === "reinforce" || semanticReinforcement)) {
285
350
  existing.evidenceCount += candidate.evidence.length;
286
351
  existing.updatedAt = new Date().toISOString();
287
352
  result.updated++;
@@ -304,8 +369,9 @@ export function commitKnowledgeReview(
304
369
  const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
305
370
  atomicWrite(join(temporary, relativeFile), renderKnowledgeFile(candidate));
306
371
  const entry: KnowledgeCatalogEntry = {
307
- id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
308
- keywords: candidate.keywords, scope: candidate.scope, track, file: relativeFile,
372
+ id, fingerprint: existing?.fingerprint ?? fingerprint, contentHash: hash,
373
+ title: candidate.title, summary: candidate.summary,
374
+ keywords: candidate.keywords, scope: existing?.scope ?? candidate.scope, track, file: relativeFile,
309
375
  evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
310
376
  createdAt: existing?.createdAt ?? now, updatedAt: now,
311
377
  };
@@ -5,6 +5,7 @@ export type KnowledgeDurability = "stable";
5
5
 
6
6
  export interface KnowledgeCandidate {
7
7
  key: string;
8
+ targetId?: string;
8
9
  title: string;
9
10
  summary: string;
10
11
  keywords: string[];
@@ -86,6 +87,7 @@ export interface KnowledgeReviewTask {
86
87
  projectKey: string;
87
88
  firstEntryId: string;
88
89
  lastEntryId: string;
90
+ context?: string;
89
91
  delta: string;
90
92
  deltaDigest: string;
91
93
  fileSize: number;
@@ -32,13 +32,17 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
32
32
  idleMs: 60_000,
33
33
  capabilityPollMs: 5_000,
34
34
  modelTimeoutMs: 180_000,
35
- maxDeltaChars: 20_000,
36
- maxCandidates: 5,
35
+ maxDeltaChars: 12_000,
36
+ maxPriorContextChars: 4_000,
37
+ maxExistingContextItems: 5,
38
+ maxExistingBodyItems: 2,
39
+ maxExistingBodyChars: 600,
40
+ maxCandidates: 3,
37
41
  minimumConfidence: 0.72,
38
42
  }),
39
43
  storage: Object.freeze({
40
- maxRuleChars: 600,
41
- maxRuleFileLines: 40,
44
+ maxRuleChars: 320,
45
+ maxRuleFileLines: 6,
42
46
  maxRulesPromptChars: 12_000,
43
47
  maxMemoryChars: 25_000,
44
48
  maxMemoryLines: 200,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {