@hadooppei/hwcode 1.0.13 → 1.0.15

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,12 +8,14 @@ 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
+ Do not persist hypotheses, suspected causes, or statements marked as possible, pending validation, or unconfirmed. Omit them until direct evidence verifies them.
11
12
  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
13
  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
14
  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
15
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
15
16
  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
17
  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.
18
+ The body must not repeat the title as a Markdown H1. The storage renderer supplies the H1. Replace any necessary example resource value with an obvious placeholder instead of a real project, namespace, image, network, or resource name.
17
19
  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
20
  Return {"candidates":[]} when nothing meets the threshold.`;
19
21
  export function buildKnowledgeExtractionPrompt(projectRoot, delta, context = "", existing = []) {
@@ -9,6 +9,9 @@ import { sanitizeKnowledgeText } from "./sanitize.js";
9
9
  import { projectKnowledgeKey } from "./store.js";
10
10
  const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
11
11
  const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
12
+ const SAFE_KNOWLEDGE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
13
+ const MAX_RECALL_QUERY_CHARS = 4_000;
14
+ const MAX_LOADED_KNOWLEDGE_IDS = 8;
12
15
  function hash(value, length = 64) {
13
16
  return createHash("sha256").update(value).digest("hex").slice(0, length);
14
17
  }
@@ -98,6 +101,110 @@ function buildValidationContext(entries) {
98
101
  return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
99
102
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
100
103
  }
104
+ function messageRecord(entry) {
105
+ return entry.type === "message" && entry.message && typeof entry.message === "object"
106
+ ? entry.message
107
+ : undefined;
108
+ }
109
+ function loadedKnowledgeIds(entries) {
110
+ const ids = [];
111
+ for (const entry of entries) {
112
+ const content = messageRecord(entry)?.content;
113
+ if (!Array.isArray(content))
114
+ continue;
115
+ for (const item of content) {
116
+ if (!item || typeof item !== "object")
117
+ continue;
118
+ const call = item;
119
+ if (call.type !== "toolCall" || call.name !== "hwcode_knowledge_lookup"
120
+ || !call.arguments || typeof call.arguments !== "object")
121
+ continue;
122
+ const id = call.arguments.id;
123
+ if (typeof id !== "string" || !SAFE_KNOWLEDGE_ID_RE.test(id))
124
+ continue;
125
+ const existing = ids.indexOf(id);
126
+ if (existing >= 0)
127
+ ids.splice(existing, 1);
128
+ ids.push(id);
129
+ }
130
+ }
131
+ return ids.slice(-MAX_LOADED_KNOWLEDGE_IDS);
132
+ }
133
+ function compactRecallValue(value, maxChars = 600) {
134
+ return typeof value === "string" ? sanitizeKnowledgeText(value).replace(/\s+/gu, " ").trim().slice(0, maxChars) : "";
135
+ }
136
+ function workflowRecallLines(entry) {
137
+ if (entry.type !== "custom" || !entry.customType.startsWith("hwcode-workflow")
138
+ || !entry.data || typeof entry.data !== "object")
139
+ return [];
140
+ const data = entry.data;
141
+ const details = data.details && typeof data.details === "object" ? data.details : undefined;
142
+ const lines = [];
143
+ const request = compactRecallValue(details?.request ?? data.request, 800);
144
+ if (request)
145
+ lines.push(`Task objective: ${request}`);
146
+ const addSteps = (label, value, maxItems) => {
147
+ if (!Array.isArray(value))
148
+ return;
149
+ for (const raw of value.slice(-maxItems)) {
150
+ if (typeof raw === "string") {
151
+ const text = compactRecallValue(raw);
152
+ if (text)
153
+ lines.push(`${label}: ${text}`);
154
+ continue;
155
+ }
156
+ if (!raw || typeof raw !== "object")
157
+ continue;
158
+ const step = raw;
159
+ const parts = [step.approach, step.intent, step.reason].map((item) => compactRecallValue(item, 320)).filter(Boolean);
160
+ if (typeof step.command === "string")
161
+ parts.push(step.command);
162
+ if (Array.isArray(step.args)) {
163
+ const operation = step.args.slice(0, 2).filter((item) => typeof item === "string")
164
+ .map((item) => compactRecallValue(item, 100)).filter(Boolean).join(" ");
165
+ if (operation)
166
+ parts.push(operation);
167
+ }
168
+ const text = [...new Set(parts)].join("; ");
169
+ if (text)
170
+ lines.push(`${label}: ${text}`);
171
+ }
172
+ };
173
+ addSteps("Verified success", details?.successfulSteps ?? data.successfulSteps, 12);
174
+ addSteps("Verified failure", details?.failedApproaches ?? data.failedApproaches, 8);
175
+ return lines;
176
+ }
177
+ function buildRecallQuery(entries, ids) {
178
+ const lines = [];
179
+ const workflows = entries.map(workflowRecallLines).filter((item) => item.length > 0);
180
+ const workflowLines = workflows.at(-1) ?? [];
181
+ lines.push(...workflowLines.filter((line) => line.startsWith("Task objective:")));
182
+ for (const entry of entries.slice().reverse()) {
183
+ const message = messageRecord(entry);
184
+ if (!message || message.role !== "user")
185
+ continue;
186
+ const text = compactRecallValue(textContent(message.content).replace(INJECTED_SKILL_RE, ""), 800);
187
+ if (text)
188
+ lines.push(`User request: ${text}`);
189
+ if (lines.filter((line) => line.startsWith("User request:")).length >= 2)
190
+ break;
191
+ }
192
+ for (const entry of entries.slice().reverse()) {
193
+ const message = messageRecord(entry);
194
+ if (!message || message.role !== "assistant")
195
+ continue;
196
+ const text = compactRecallValue(textContent(message.content), 1_000);
197
+ if (text) {
198
+ lines.push(`Latest conclusion: ${text}`);
199
+ break;
200
+ }
201
+ }
202
+ for (const id of ids)
203
+ lines.push(`Loaded knowledge: ${id}`);
204
+ lines.push(...workflowLines.filter((line) => line.startsWith("Verified failure:")));
205
+ lines.push(...workflowLines.filter((line) => line.startsWith("Verified success:")));
206
+ return lines.join("\n").slice(0, MAX_RECALL_QUERY_CHARS);
207
+ }
101
208
  function projectRootForSession(header, branch) {
102
209
  const sessionRoot = resolve(header.cwd);
103
210
  const workflowRootValue = findLatestWorkflowRoot(branch);
@@ -176,10 +283,12 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
176
283
  if (pending.length === 0)
177
284
  return undefined;
178
285
  const delta = buildDelta(pending);
286
+ const loadedIds = loadedKnowledgeIds(pending);
287
+ const recallQuery = buildRecallQuery(pending, loadedIds);
179
288
  const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
180
289
  const firstEntryId = pending[0].id;
181
290
  const lastEntryId = pending[pending.length - 1].id;
182
- const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
291
+ const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
183
292
  const sessionFileHash = hash(resolve(sessionFile), 16);
184
293
  const sessionKey = hash(header.id, 24);
185
294
  const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
@@ -187,7 +296,8 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
187
296
  return {
188
297
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
189
298
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
190
- firstEntryId, lastEntryId, context, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
299
+ firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
300
+ deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
191
301
  };
192
302
  }
193
303
  export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
@@ -9,6 +9,12 @@ 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;
11
11
  const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
12
+ const UNVERIFIED_CLAIM_RE = /(?:待验证|尚未(?:验证|确认)|未经(?:验证|确认)|未(?:经)?证实|推测|猜测|疑似|可能是|可能由于|可能导致|或许|unverified|unconfirmed|speculat(?:e|ive|ion)|hypothes(?:is|ize)|suspect(?:ed|ion)?|possibly|probably)/iu;
13
+ const VOLATILE_ARGUMENT_RE = /--(?:namespace|name|server_name|security_group_id|subnet_id|vpc_id|image_id|project_id)(?:\.\d+)?=(?!<[^>]+>|\$?\{)[^\s`"']+/iu;
14
+ const DISTINCTIVE_COMMON_TERMS = new Set([
15
+ "cloud", "huawei", "huaweicloud", "hcloud", "topic", "rule", "sop", "project", "region", "create",
16
+ "deploy", "deployment", "service", "workflow", "cn", "south",
17
+ ]);
12
18
  export class KnowledgeCommitBusyError extends Error {
13
19
  }
14
20
  function emptyCatalog() {
@@ -120,9 +126,14 @@ function semanticDuplicateScore(candidate, entry) {
120
126
  return 0;
121
127
  const titleScore = containment(comparisonTerms(candidate.title), comparisonTerms(entry.title));
122
128
  const keywordScore = containment(comparisonTerms(candidate.keywords.join(" ")), comparisonTerms(entry.keywords.join(" ")));
123
- if (titleScore < 0.8 || keywordScore < 0.5)
129
+ if (titleScore >= 0.8 && keywordScore >= 0.5)
130
+ return titleScore * 0.7 + keywordScore * 0.3;
131
+ const candidateTerms = comparisonTerms(`${candidate.title} ${candidate.keywords.join(" ")}`);
132
+ const entryTerms = comparisonTerms(`${entry.title} ${entry.keywords.join(" ")}`);
133
+ const distinctiveShared = [...candidateTerms].filter((term) => (entryTerms.has(term) && term.length >= 3 && !DISTINCTIVE_COMMON_TERMS.has(term))).length;
134
+ if (keywordScore < 0.5 || distinctiveShared < 3)
124
135
  return 0;
125
- return titleScore * 0.7 + keywordScore * 0.3;
136
+ return keywordScore * 0.7 + Math.min(distinctiveShared / 6, 1) * 0.3;
126
137
  }
127
138
  function semanticDuplicateIndex(candidate, catalog) {
128
139
  let bestIndex = -1;
@@ -167,7 +178,19 @@ function asStringArray(value, maxItems) {
167
178
  return [...new Set(value.filter((item) => typeof item === "string")
168
179
  .map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
169
180
  }
170
- function normalizeCandidate(value, projectKey, validationText) {
181
+ function withoutLeadingTitle(body) {
182
+ return body.replace(/^#\s+[^\n]*(?:\n+|$)/u, "").trim();
183
+ }
184
+ function containsVolatileDetail(body, projectRoot) {
185
+ const projectName = basename(resolve(projectRoot));
186
+ const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
187
+ const namedProject = projectName.length >= 5
188
+ && new RegExp(`(?:项目|project)\\s*(?:[::=]|is)?\\s*[\`'\"]?${escapedProjectName}(?![\\p{L}\\p{N}-])`, "iu").test(body);
189
+ return VOLATILE_ARGUMENT_RE.test(body)
190
+ || namedProject
191
+ || /\.hwcode\/cloud\/runs\//iu.test(body);
192
+ }
193
+ function normalizeCandidate(value, projectKey, projectRoot, validationText) {
171
194
  if (!value || typeof value !== "object")
172
195
  return { reason: "invalid-schema" };
173
196
  const raw = value;
@@ -183,10 +206,15 @@ function normalizeCandidate(value, projectKey, validationText) {
183
206
  return { reason: "unknown-target" };
184
207
  const explicitUserDirective = raw.explicitUserDirective === true;
185
208
  const scope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
186
- const body = compactKnowledgeText(raw.body, 20_000);
209
+ const body = withoutLeadingTitle(compactKnowledgeText(raw.body, 20_000));
187
210
  const evidence = asStringArray(raw.evidence, 8);
188
211
  if (!body || evidence.length === 0)
189
212
  return { reason: "missing-body-or-evidence" };
213
+ const candidateClaims = `${raw.title}\n${raw.summary}\n${body}`;
214
+ if (!explicitUserDirective && UNVERIFIED_CLAIM_RE.test(candidateClaims))
215
+ return { reason: "unverified-claim" };
216
+ if (containsVolatileDetail(body, projectRoot))
217
+ return { reason: "volatile-detail" };
190
218
  if (contradictsVerifiedFailures(body, validationText))
191
219
  return { reason: "contradicts-verified-failure" };
192
220
  const requestedTrack = raw.storageHint === "rule" ? "rule" : "topic";
@@ -352,7 +380,7 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
352
380
  const result = { saved: 0, updated: 0, pending: 0, skipped: 0 };
353
381
  const rejected = [];
354
382
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
355
- const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
383
+ const normalized = normalizeCandidate(value, task.projectKey, task.projectRoot, `${task.context ?? ""}\n${task.delta}`);
356
384
  if (!("candidate" in normalized)) {
357
385
  recordSkip(result, normalized.reason);
358
386
  rejected.push({ reason: normalized.reason, value });
@@ -10,7 +10,7 @@ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-
10
10
  import {
11
11
  buildKnowledgeExtractionPrompt, type ExistingKnowledgeContext, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
12
12
  } from "../lib/knowledge/extractor.ts";
13
- import { matchKnowledge } from "../lib/knowledge/matcher.ts";
13
+ import { matchKnowledge, recallKnowledge } from "../lib/knowledge/matcher.ts";
14
14
  import { loadKnowledgeById, loadKnowledgeSnapshot, projectKnowledgeKey } from "../lib/knowledge/store.ts";
15
15
  import type { KnowledgeWorkerInput, KnowledgeWorkerOutput } from "../lib/knowledge/worker-protocol.ts";
16
16
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../lib/runtime/defaults.ts";
@@ -20,6 +20,7 @@ interface ActiveReview {
20
20
  requestId: string;
21
21
  leaderToken: string;
22
22
  controller: AbortController;
23
+ abortReason?: string;
23
24
  }
24
25
 
25
26
  interface ProcessKnowledgeRuntime {
@@ -58,8 +59,17 @@ function updateCapability(): void {
58
59
  post({ type: "configure", modelAvailable: available, sessionsRoot: runtime.sessionsRoot });
59
60
  }
60
61
 
61
- function cancelActiveReview(): void {
62
- runtime.activeReview?.controller.abort();
62
+ function cancelActiveReview(reason: string): void {
63
+ const review = runtime.activeReview;
64
+ if (!review || review.controller.signal.aborted) return;
65
+ review.abortReason = reason;
66
+ review.controller.abort();
67
+ }
68
+
69
+ export function isExpectedReviewInterruption(reason: string): boolean {
70
+ return reason === "foreground-model-became-busy"
71
+ || reason === "session-shutdown"
72
+ || reason === "knowledge-worker-restarting";
63
73
  }
64
74
 
65
75
  function reportWorkerIssue(error: unknown): void {
@@ -95,6 +105,7 @@ function workerEntryUrl(): URL {
95
105
 
96
106
  async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review_request" }>): Promise<void> {
97
107
  let timeout: NodeJS.Timeout | undefined;
108
+ let review: ActiveReview | undefined;
98
109
  try {
99
110
  const ctx = runtime.context;
100
111
  if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) throw new Error("no-configured-model");
@@ -108,8 +119,13 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
108
119
  return;
109
120
  }
110
121
  const controller = new AbortController();
111
- runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
112
- timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
122
+ review = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
123
+ runtime.activeReview = review;
124
+ timeout = setTimeout(() => {
125
+ if (controller.signal.aborted) return;
126
+ review!.abortReason = "knowledge-review-model-timeout";
127
+ controller.abort();
128
+ }, KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
113
129
  timeout.unref();
114
130
  const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
115
131
  const applicableCatalog = {
@@ -118,9 +134,10 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
118
134
  entry.scope === "global" || entry.scope === `project:${message.task.projectKey}`
119
135
  )),
120
136
  };
121
- const related: ExistingKnowledgeContext[] = matchKnowledge(
122
- message.task.delta,
137
+ const related: ExistingKnowledgeContext[] = recallKnowledge(
138
+ message.task.recallQuery || message.task.delta,
123
139
  applicableCatalog,
140
+ message.task.loadedKnowledgeIds ?? [],
124
141
  KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
125
142
  ).map((entry, index) => {
126
143
  const reference: ExistingKnowledgeContext = {
@@ -155,13 +172,15 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
155
172
  },
156
173
  { signal: controller.signal, reasoningEffort: "low", cacheRetention: "none", sessionId: randomUUID() },
157
174
  );
158
- if (controller.signal.aborted) throw new Error("knowledge-review-aborted-or-timed-out");
175
+ if (controller.signal.aborted) throw new Error(review.abortReason ?? "knowledge-review-interrupted");
159
176
  const raw = response.content.filter((item): item is { type: "text"; text: string } => item.type === "text")
160
177
  .map((item) => item.text).join("\n");
161
178
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
162
179
  } catch (error) {
163
- const reason = error instanceof Error ? error.message : String(error);
164
- if (reason === "model-executor-is-busy") {
180
+ const reason = review?.controller.signal.aborted
181
+ ? review.abortReason ?? "knowledge-review-interrupted"
182
+ : error instanceof Error ? error.message : String(error);
183
+ if (reason === "model-executor-is-busy" || isExpectedReviewInterruption(reason)) {
165
184
  post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
166
185
  } else {
167
186
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
@@ -181,7 +200,7 @@ function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
181
200
  }
182
201
  if (message.type === "review_request") { void runReview(message); return; }
183
202
  if (message.type === "review_cancel" && runtime.activeReview?.requestId === message.requestId) {
184
- runtime.activeReview.controller.abort();
203
+ cancelActiveReview(message.reason);
185
204
  return;
186
205
  }
187
206
  if (message.type === "review_failed") reportWorkerIssue(message.error);
@@ -196,7 +215,7 @@ function ensureWorker(): Worker | undefined {
196
215
  worker.on("message", (message: KnowledgeWorkerOutput) => handleWorkerMessage(message));
197
216
  worker.on("error", (error) => {
198
217
  reportWorkerIssue(error);
199
- cancelActiveReview();
218
+ cancelActiveReview("knowledge-worker-restarting");
200
219
  if (runtime.worker === worker) {
201
220
  runtime.worker = undefined;
202
221
  runtime.modelAvailable = undefined;
@@ -292,14 +311,14 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
292
311
 
293
312
  pi.on("input", (_event, ctx) => {
294
313
  runtime.context = ctx;
295
- cancelActiveReview();
314
+ cancelActiveReview("foreground-model-became-busy");
296
315
  updateCapability();
297
316
  return undefined;
298
317
  });
299
318
 
300
319
  pi.on("agent_start", (_event, ctx) => {
301
320
  runtime.context = ctx;
302
- cancelActiveReview();
321
+ cancelActiveReview("foreground-model-became-busy");
303
322
  });
304
323
 
305
324
  pi.on("agent_settled", (_event, ctx) => {
@@ -309,7 +328,7 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
309
328
  });
310
329
 
311
330
  pi.on("session_shutdown", (event) => {
312
- cancelActiveReview();
331
+ cancelActiveReview("session-shutdown");
313
332
  if (event.reason === "quit") {
314
333
  runtime.context = undefined;
315
334
  updateCapability();
@@ -19,12 +19,14 @@ Persist only knowledge that is likely to be useful in future sessions:
19
19
  - reusable architecture, testing, debugging, deployment, or operational knowledge.
20
20
  Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
21
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
+ Do not persist hypotheses, suspected causes, or statements marked as possible, pending validation, or unconfirmed. Omit them until direct evidence verifies them.
22
23
  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
24
  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
25
  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.
25
26
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
26
27
  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
28
  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.
29
+ The body must not repeat the title as a Markdown H1. The storage renderer supplies the H1. Replace any necessary example resource value with an obvious placeholder instead of a real project, namespace, image, network, or resource name.
28
30
  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"}]}
29
31
  Return {"candidates":[]} when nothing meets the threshold.`;
30
32
 
@@ -80,7 +80,7 @@ function phraseScore(query: string, title: SearchField, summary: SearchField, ke
80
80
  export function matchKnowledge(
81
81
  query: string,
82
82
  catalog: KnowledgeCatalog,
83
- limit = KNOWLEDGE_RUNTIME_DEFAULTS.storage.maxLookupResults,
83
+ limit: number = KNOWLEDGE_RUNTIME_DEFAULTS.storage.maxLookupResults,
84
84
  ): KnowledgeCatalogEntry[] {
85
85
  const normalizedQuery = normalizeSearchText(query);
86
86
  const compactQuery = normalizedQuery.replace(/\s+/gu, "");
@@ -120,3 +120,38 @@ export function matchKnowledge(
120
120
  .slice(0, limit)
121
121
  .map(({ item }) => item);
122
122
  }
123
+
124
+ /** Keeps explicitly loaded knowledge in review context, then fills the remainder with cheap lexical recall. */
125
+ export function recallKnowledge(
126
+ query: string,
127
+ catalog: KnowledgeCatalog,
128
+ preferredIds: string[],
129
+ limit: number = KNOWLEDGE_RUNTIME_DEFAULTS.storage.maxLookupResults,
130
+ ): KnowledgeCatalogEntry[] {
131
+ if (limit <= 0) return [];
132
+ const recalled: KnowledgeCatalogEntry[] = [];
133
+ const seen = new Set<string>();
134
+ const lexicalMatches = matchKnowledge(query, catalog, catalog.items.length);
135
+ const lexicalRank = new Map(lexicalMatches.map((entry, index) => [entry.id, index]));
136
+ const preferred = preferredIds.map((id, index) => ({
137
+ entry: catalog.items.find((item) => item.id === id), index,
138
+ })).filter((item): item is { entry: KnowledgeCatalogEntry; index: number } => Boolean(item.entry))
139
+ .sort((left, right) => (
140
+ (lexicalRank.get(left.entry.id) ?? Number.MAX_SAFE_INTEGER)
141
+ - (lexicalRank.get(right.entry.id) ?? Number.MAX_SAFE_INTEGER)
142
+ || left.index - right.index
143
+ ));
144
+ for (const { entry } of preferred) {
145
+ if (seen.has(entry.id)) continue;
146
+ recalled.push(entry);
147
+ seen.add(entry.id);
148
+ if (recalled.length >= limit) return recalled;
149
+ }
150
+ for (const entry of lexicalMatches) {
151
+ if (seen.has(entry.id)) continue;
152
+ recalled.push(entry);
153
+ seen.add(entry.id);
154
+ if (recalled.length >= limit) break;
155
+ }
156
+ return recalled;
157
+ }
@@ -22,6 +22,9 @@ interface ReviewPiece {
22
22
 
23
23
  const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
24
24
  const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
25
+ const SAFE_KNOWLEDGE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
26
+ const MAX_RECALL_QUERY_CHARS = 4_000;
27
+ const MAX_LOADED_KNOWLEDGE_IDS = 8;
25
28
 
26
29
  function hash(value: string, length = 64): string {
27
30
  return createHash("sha256").update(value).digest("hex").slice(0, length);
@@ -105,6 +108,96 @@ function buildValidationContext(entries: SessionEntry[]): string {
105
108
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
106
109
  }
107
110
 
111
+ function messageRecord(entry: SessionEntry): Record<string, unknown> | undefined {
112
+ return entry.type === "message" && entry.message && typeof entry.message === "object"
113
+ ? entry.message as unknown as Record<string, unknown>
114
+ : undefined;
115
+ }
116
+
117
+ function loadedKnowledgeIds(entries: SessionEntry[]): string[] {
118
+ const ids: string[] = [];
119
+ for (const entry of entries) {
120
+ const content = messageRecord(entry)?.content;
121
+ if (!Array.isArray(content)) continue;
122
+ for (const item of content) {
123
+ if (!item || typeof item !== "object") continue;
124
+ const call = item as Record<string, unknown>;
125
+ if (call.type !== "toolCall" || call.name !== "hwcode_knowledge_lookup"
126
+ || !call.arguments || typeof call.arguments !== "object") continue;
127
+ const id = (call.arguments as Record<string, unknown>).id;
128
+ if (typeof id !== "string" || !SAFE_KNOWLEDGE_ID_RE.test(id)) continue;
129
+ const existing = ids.indexOf(id);
130
+ if (existing >= 0) ids.splice(existing, 1);
131
+ ids.push(id);
132
+ }
133
+ }
134
+ return ids.slice(-MAX_LOADED_KNOWLEDGE_IDS);
135
+ }
136
+
137
+ function compactRecallValue(value: unknown, maxChars = 600): string {
138
+ return typeof value === "string" ? sanitizeKnowledgeText(value).replace(/\s+/gu, " ").trim().slice(0, maxChars) : "";
139
+ }
140
+
141
+ function workflowRecallLines(entry: SessionEntry): string[] {
142
+ if (entry.type !== "custom" || !entry.customType.startsWith("hwcode-workflow")
143
+ || !entry.data || typeof entry.data !== "object") return [];
144
+ const data = entry.data as Record<string, unknown>;
145
+ const details = data.details && typeof data.details === "object" ? data.details as Record<string, unknown> : undefined;
146
+ const lines: string[] = [];
147
+ const request = compactRecallValue(details?.request ?? data.request, 800);
148
+ if (request) lines.push(`Task objective: ${request}`);
149
+
150
+ const addSteps = (label: string, value: unknown, maxItems: number): void => {
151
+ if (!Array.isArray(value)) return;
152
+ for (const raw of value.slice(-maxItems)) {
153
+ if (typeof raw === "string") {
154
+ const text = compactRecallValue(raw);
155
+ if (text) lines.push(`${label}: ${text}`);
156
+ continue;
157
+ }
158
+ if (!raw || typeof raw !== "object") continue;
159
+ const step = raw as Record<string, unknown>;
160
+ const parts = [step.approach, step.intent, step.reason].map((item) => compactRecallValue(item, 320)).filter(Boolean);
161
+ if (typeof step.command === "string") parts.push(step.command);
162
+ if (Array.isArray(step.args)) {
163
+ const operation = step.args.slice(0, 2).filter((item): item is string => typeof item === "string")
164
+ .map((item) => compactRecallValue(item, 100)).filter(Boolean).join(" ");
165
+ if (operation) parts.push(operation);
166
+ }
167
+ const text = [...new Set(parts)].join("; ");
168
+ if (text) lines.push(`${label}: ${text}`);
169
+ }
170
+ };
171
+ addSteps("Verified success", details?.successfulSteps ?? data.successfulSteps, 12);
172
+ addSteps("Verified failure", details?.failedApproaches ?? data.failedApproaches, 8);
173
+ return lines;
174
+ }
175
+
176
+ function buildRecallQuery(entries: SessionEntry[], ids: string[]): string {
177
+ const lines: string[] = [];
178
+ const workflows = entries.map(workflowRecallLines).filter((item) => item.length > 0);
179
+ const workflowLines = workflows.at(-1) ?? [];
180
+ lines.push(...workflowLines.filter((line) => line.startsWith("Task objective:")));
181
+
182
+ for (const entry of entries.slice().reverse()) {
183
+ const message = messageRecord(entry);
184
+ if (!message || message.role !== "user") continue;
185
+ const text = compactRecallValue(textContent(message.content).replace(INJECTED_SKILL_RE, ""), 800);
186
+ if (text) lines.push(`User request: ${text}`);
187
+ if (lines.filter((line) => line.startsWith("User request:")).length >= 2) break;
188
+ }
189
+ for (const entry of entries.slice().reverse()) {
190
+ const message = messageRecord(entry);
191
+ if (!message || message.role !== "assistant") continue;
192
+ const text = compactRecallValue(textContent(message.content), 1_000);
193
+ if (text) { lines.push(`Latest conclusion: ${text}`); break; }
194
+ }
195
+ for (const id of ids) lines.push(`Loaded knowledge: ${id}`);
196
+ lines.push(...workflowLines.filter((line) => line.startsWith("Verified failure:")));
197
+ lines.push(...workflowLines.filter((line) => line.startsWith("Verified success:")));
198
+ return lines.join("\n").slice(0, MAX_RECALL_QUERY_CHARS);
199
+ }
200
+
108
201
  function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
109
202
  const sessionRoot = resolve(header.cwd);
110
203
  const workflowRootValue = findLatestWorkflowRoot(branch);
@@ -164,10 +257,12 @@ export function readReviewTask(
164
257
  const pending = branch.slice(cursorIndex + 1);
165
258
  if (pending.length === 0) return undefined;
166
259
  const delta = buildDelta(pending);
260
+ const loadedIds = loadedKnowledgeIds(pending);
261
+ const recallQuery = buildRecallQuery(pending, loadedIds);
167
262
  const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
168
263
  const firstEntryId = pending[0].id;
169
264
  const lastEntryId = pending[pending.length - 1].id;
170
- const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
265
+ const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
171
266
  const sessionFileHash = hash(resolve(sessionFile), 16);
172
267
  const sessionKey = hash(header.id, 24);
173
268
  const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
@@ -175,7 +270,8 @@ export function readReviewTask(
175
270
  return {
176
271
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
177
272
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
178
- firstEntryId, lastEntryId, context, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
273
+ firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
274
+ deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
179
275
  };
180
276
  }
181
277
 
@@ -19,6 +19,12 @@ const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
19
19
  const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
20
20
  const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
21
21
  const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
22
+ const UNVERIFIED_CLAIM_RE = /(?:待验证|尚未(?:验证|确认)|未经(?:验证|确认)|未(?:经)?证实|推测|猜测|疑似|可能是|可能由于|可能导致|或许|unverified|unconfirmed|speculat(?:e|ive|ion)|hypothes(?:is|ize)|suspect(?:ed|ion)?|possibly|probably)/iu;
23
+ const VOLATILE_ARGUMENT_RE = /--(?:namespace|name|server_name|security_group_id|subnet_id|vpc_id|image_id|project_id)(?:\.\d+)?=(?!<[^>]+>|\$?\{)[^\s`"']+/iu;
24
+ const DISTINCTIVE_COMMON_TERMS = new Set([
25
+ "cloud", "huawei", "huaweicloud", "hcloud", "topic", "rule", "sop", "project", "region", "create",
26
+ "deploy", "deployment", "service", "workflow", "cn", "south",
27
+ ]);
22
28
 
23
29
  export class KnowledgeCommitBusyError extends Error {}
24
30
 
@@ -138,8 +144,14 @@ function semanticDuplicateScore(candidate: KnowledgeCandidate, entry: KnowledgeC
138
144
  comparisonTerms(candidate.keywords.join(" ")),
139
145
  comparisonTerms(entry.keywords.join(" ")),
140
146
  );
141
- if (titleScore < 0.8 || keywordScore < 0.5) return 0;
142
- return titleScore * 0.7 + keywordScore * 0.3;
147
+ if (titleScore >= 0.8 && keywordScore >= 0.5) return titleScore * 0.7 + keywordScore * 0.3;
148
+ const candidateTerms = comparisonTerms(`${candidate.title} ${candidate.keywords.join(" ")}`);
149
+ const entryTerms = comparisonTerms(`${entry.title} ${entry.keywords.join(" ")}`);
150
+ const distinctiveShared = [...candidateTerms].filter((term) => (
151
+ entryTerms.has(term) && term.length >= 3 && !DISTINCTIVE_COMMON_TERMS.has(term)
152
+ )).length;
153
+ if (keywordScore < 0.5 || distinctiveShared < 3) return 0;
154
+ return keywordScore * 0.7 + Math.min(distinctiveShared / 6, 1) * 0.3;
143
155
  }
144
156
 
145
157
  function semanticDuplicateIndex(candidate: KnowledgeCandidate, catalog: KnowledgeCatalog): number {
@@ -191,7 +203,23 @@ function asStringArray(value: unknown, maxItems: number): string[] {
191
203
 
192
204
  type CandidateNormalization = { candidate: KnowledgeCandidate } | { reason: KnowledgeSkipReason };
193
205
 
194
- function normalizeCandidate(value: unknown, projectKey: string, validationText: string): CandidateNormalization {
206
+ function withoutLeadingTitle(body: string): string {
207
+ return body.replace(/^#\s+[^\n]*(?:\n+|$)/u, "").trim();
208
+ }
209
+
210
+ function containsVolatileDetail(body: string, projectRoot: string): boolean {
211
+ const projectName = basename(resolve(projectRoot));
212
+ const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
213
+ const namedProject = projectName.length >= 5
214
+ && new RegExp(`(?:项目|project)\\s*(?:[::=]|is)?\\s*[\`'\"]?${escapedProjectName}(?![\\p{L}\\p{N}-])`, "iu").test(body);
215
+ return VOLATILE_ARGUMENT_RE.test(body)
216
+ || namedProject
217
+ || /\.hwcode\/cloud\/runs\//iu.test(body);
218
+ }
219
+
220
+ function normalizeCandidate(
221
+ value: unknown, projectKey: string, projectRoot: string, validationText: string,
222
+ ): CandidateNormalization {
195
223
  if (!value || typeof value !== "object") return { reason: "invalid-schema" };
196
224
  const raw = value as Record<string, unknown>;
197
225
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
@@ -202,9 +230,12 @@ function normalizeCandidate(value: unknown, projectKey: string, validationText:
202
230
  && (typeof raw.targetId !== "string" || !SAFE_ID_RE.test(raw.targetId))) return { reason: "unknown-target" };
203
231
  const explicitUserDirective = raw.explicitUserDirective === true;
204
232
  const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
205
- const body = compactKnowledgeText(raw.body, 20_000);
233
+ const body = withoutLeadingTitle(compactKnowledgeText(raw.body, 20_000));
206
234
  const evidence = asStringArray(raw.evidence, 8);
207
235
  if (!body || evidence.length === 0) return { reason: "missing-body-or-evidence" };
236
+ const candidateClaims = `${raw.title}\n${raw.summary}\n${body}`;
237
+ if (!explicitUserDirective && UNVERIFIED_CLAIM_RE.test(candidateClaims)) return { reason: "unverified-claim" };
238
+ if (containsVolatileDetail(body, projectRoot)) return { reason: "volatile-detail" };
208
239
  if (contradictsVerifiedFailures(body, validationText)) return { reason: "contradicts-verified-failure" };
209
240
  const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
210
241
  const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
@@ -360,7 +391,9 @@ export function commitKnowledgeReview(
360
391
  const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
361
392
  const rejected: Array<{ reason: KnowledgeSkipReason; value: unknown }> = [];
362
393
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
363
- const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
394
+ const normalized = normalizeCandidate(
395
+ value, task.projectKey, task.projectRoot, `${task.context ?? ""}\n${task.delta}`,
396
+ );
364
397
  if (!("candidate" in normalized)) {
365
398
  recordSkip(result, normalized.reason);
366
399
  rejected.push({ reason: normalized.reason, value });
@@ -81,6 +81,8 @@ export interface PersistKnowledgeResult {
81
81
  export type KnowledgeSkipReason =
82
82
  | "invalid-schema"
83
83
  | "unstable"
84
+ | "unverified-claim"
85
+ | "volatile-detail"
84
86
  | "low-confidence"
85
87
  | "missing-body-or-evidence"
86
88
  | "contradicts-verified-failure"
@@ -98,6 +100,8 @@ export interface KnowledgeReviewTask {
98
100
  lastEntryId: string;
99
101
  context?: string;
100
102
  delta: string;
103
+ recallQuery?: string;
104
+ loadedKnowledgeIds?: string[];
101
105
  deltaDigest: string;
102
106
  fileSize: number;
103
107
  fileMtimeMs: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {