@hadooppei/hwcode 1.0.24 → 1.0.25

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.
@@ -299,5 +299,5 @@ export function buildKnowledgeEpisodes(evidence) {
299
299
  ? episodes.filter((episode) => !episode.id.includes("-general-reusable-work-") || episode.userSignal)
300
300
  : episodes;
301
301
  return retained.sort((left, right) => right.score - left.score || right.evidenceIds.length - left.evidenceIds.length)
302
- .slice(0, 6);
302
+ .slice(0, 2);
303
303
  }
@@ -14,14 +14,16 @@ Treat failed, unsupported, invalid, or corrected operations as negative evidence
14
14
  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.
15
15
  Rank knowledge by future time saved, repeated failures, hypothesis reversals, user corrections, verified resolution, reuse, and novelty. Do not favor the final task result merely because it completed the objective. Every supplied episode scoring 45 or higher must have an episodeDecisions entry, even when skipped.
16
16
  Relevant existing knowledge may be supplied. Cheap recall only proposes related items; it does not prove duplication. Compare execution environment, symptom, and root cause. Use action "reinforce" only when the exact target already covers every claim and there is no new content. Use "extend" for new compatible claims, "correct" when direct evidence disproves old content, "retire" when the whole target is invalid, and "add" for a distinct environment or root cause. Never turn lexical similarity into reinforcement.
17
- Prefer the most specific existing topic that already covers a claim over a broad conventions or SOP topic.
17
+ For every candidate, first compare the most specific recalled existing topic. When it covers the same concrete failure environment, symptom, root cause, or verified workaround, you must target that topic with "reinforce", "extend", or "correct" as warranted instead of adding knowledge. Prefer the concrete fault topic over broad conventions, generic SOP topics, or incidental access facts such as a temporary SSH user, port, or resource name.
18
18
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
19
19
  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.
20
20
  Every technical claim must have a stable claim key and cite evidenceIds from the supplied evidence ledger. Tool success, tool failure, and verification evidence outrank assistant narration. Do not support a service-wide or universal claim with one operation-specific failure. Evidence for an unrelated parameter error cannot support an API-version claim.
21
- Keep each candidate atomic: put separable claim sets in separate candidates, omit any claim without direct cited evidence, and prioritize the highest-value supported candidates. One unsupported claim rejects its entire candidate.
22
- 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.
21
+ Keep each candidate atomic: put separable claim sets in separate candidates, omit any claim without direct cited evidence, and select only the two highest-value supported information items. One unsupported claim rejects its entire candidate.
22
+ Classify every candidate as exactly one category: "principle" for a durable invariant or rule; "configuration" for a verified setting or parameter contract; "method" for a repeatable procedure; "strategy" for reusable diagnosis or design reasoning; or "caution" for a trap, failure signature, or boundary.
23
+ Return compact JSON intended for a background task: at most 2 candidates; each summary at most 120 characters; each topic body at most 800 characters; each episode decision reason at most 120 characters; and all candidate bodies combined at most 1400 characters. Prefer fewer complete high-value candidates over filling the limit. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered.
23
24
  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.
24
- Return: {"episodeDecisions":[{"episodeId":"ep-id","decision":"add|reinforce|extend|correct|retire|skip","targetId":"existing-id or omitted","reason":"..."}],"candidates":[{"key":"stable semantic key","identityKey":"platform/runtime/problem/root-cause","targetId":"existing-id or null","episodeIds":["ep-id"],"title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body or only the new section for extend","claims":[{"key":"stable claim key","text":"one precise claim","evidenceIds":["ev-id"]}],"evidenceIds":["ev-id"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|extend|correct|retire","explicitUserDirective":false,"durability":"stable"}]}
25
+ Evidence IDs are short handles such as "e1" and "e2". Copy them exactly from the evidence ledger.
26
+ Return: {"episodeDecisions":[{"episodeId":"ep-id","decision":"add|reinforce|extend|correct|retire|skip","targetId":"existing-id or omitted","reason":"..."}],"candidates":[{"key":"stable semantic key","category":"principle|configuration|method|strategy|caution","identityKey":"platform/runtime/problem/root-cause","targetId":"existing-id or null","episodeIds":["ep-id"],"title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body or only the new section for extend","claims":[{"key":"stable claim key","text":"one precise claim","evidenceIds":["e1"]}],"evidenceIds":["e1"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|extend|correct|retire","explicitUserDirective":false,"durability":"stable"}]}
25
27
  Return {"episodeDecisions":[],"candidates":[]} only when there are no mandatory episodes and nothing meets the threshold.`;
26
28
  export function buildKnowledgeExtractionPrompt(projectRoot, delta, context = "", existing = [], evidence = [], episodes = []) {
27
29
  const related = existing.length > 0 ? JSON.stringify(existing) : "[]";
@@ -312,7 +312,9 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
312
312
  return undefined;
313
313
  const pendingEntryIds = new Set(pending.map((entry) => entry.id));
314
314
  const evidenceWindow = cursorIndex >= 0 ? branch.slice(Math.max(0, cursorIndex - 40)) : pending;
315
- const evidence = buildKnowledgeEvidence(evidenceWindow).filter((item) => pendingEntryIds.has(item.entryId));
315
+ const evidence = buildKnowledgeEvidence(evidenceWindow)
316
+ .filter((item) => pendingEntryIds.has(item.entryId))
317
+ .map((item, index) => ({ ...item, id: `e${index + 1}` }));
316
318
  const episodes = buildKnowledgeEpisodes(evidence);
317
319
  const lookupResultEntryIds = knowledgeLookupResultEntryIds(branch);
318
320
  const delta = buildDelta(pending, lookupResultEntryIds);
@@ -11,6 +11,9 @@ const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
11
11
  const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
12
12
  const UNVERIFIED_CLAIM_RE = /(?:待验证|尚未(?:验证|确认)|未经(?:验证|确认)|未(?:经)?证实|推测|猜测|疑似|可能是|可能由于|可能导致|或许|unverified|unconfirmed|speculat(?:e|ive|ion)|hypothes(?:is|ize)|suspect(?:ed|ion)?|possibly|probably)/iu;
13
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 KNOWLEDGE_CATEGORIES = new Set([
15
+ "principle", "configuration", "method", "strategy", "caution",
16
+ ]);
14
17
  export class KnowledgeCommitBusyError extends Error {
15
18
  }
16
19
  function emptyCatalog() {
@@ -219,6 +222,9 @@ function normalizeCandidate(value, task, validationText) {
219
222
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
220
223
  || typeof raw.body !== "string" || typeof raw.confidence !== "number")
221
224
  return { reason: "invalid-schema" };
225
+ if (typeof raw.category !== "string" || !KNOWLEDGE_CATEGORIES.has(raw.category)) {
226
+ return { reason: "invalid-schema" };
227
+ }
222
228
  if (raw.durability !== "stable")
223
229
  return { reason: "unstable" };
224
230
  if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence)
@@ -283,6 +289,7 @@ function normalizeCandidate(value, task, validationText) {
283
289
  || raw.action === "retire" ? raw.action : "add";
284
290
  return { candidate: {
285
291
  key: compactKnowledgeText(raw.key, 160),
292
+ category: raw.category,
286
293
  identityKey: typeof raw.identityKey === "string" ? compactKnowledgeText(raw.identityKey, 240).toLowerCase() : undefined,
287
294
  targetId,
288
295
  title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
@@ -315,7 +322,8 @@ function renderKnowledgeFile(candidate) {
315
322
  if (candidate.storageHint === "rule")
316
323
  return `# ${candidate.title}\n\n${candidate.body}\n`;
317
324
  return [
318
- `# ${candidate.title}`, "", candidate.summary, "", `Keywords: ${candidate.keywords.join(", ")}`, "",
325
+ `# ${candidate.title}`, "", candidate.summary, "", `Category: ${candidate.category}`,
326
+ `Keywords: ${candidate.keywords.join(", ")}`, "",
319
327
  candidate.body, "", "## Evidence", "", ...candidate.evidence.map((item) => `- ${item}`), "",
320
328
  ].join("\n");
321
329
  }
@@ -342,7 +350,8 @@ function generateMemory(catalog, projectKey) {
342
350
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
343
351
  for (const item of topics) {
344
352
  const scope = item.scope === "global" ? "global" : "project";
345
- const line = `- [${item.id}] (${scope}; ${item.keywords.join(", ")}) ${item.title}: ${item.summary}`;
353
+ const category = item.category ? `${item.category}; ` : "";
354
+ const line = `- [${item.id}] (${category}${scope}; ${item.keywords.join(", ")}) ${item.title}: ${item.summary}`;
346
355
  if (lines.length + 1 >= STORAGE.maxMemoryLines || [...lines, line].join("\n").length > STORAGE.maxMemoryChars) {
347
356
  lines.push("- Additional topics remain searchable through hwcode_knowledge_lookup(query: \"keywords\").");
348
357
  break;
@@ -493,6 +502,7 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
493
502
  continue;
494
503
  }
495
504
  existing.evidenceCount += candidate.evidence.length;
505
+ existing.category ??= candidate.category;
496
506
  existing.updatedAt = new Date().toISOString();
497
507
  result.updated++;
498
508
  continue;
@@ -535,6 +545,7 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
535
545
  existing.contentHash = createHash("sha256").update(content).digest("hex");
536
546
  existing.keywords = [...new Set([...existing.keywords, ...candidate.keywords])].slice(0, STORAGE.maxKeywordCount);
537
547
  existing.claimKeys = [...new Set([...(existing.claimKeys ?? []), ...newClaimKeys])];
548
+ existing.category ??= candidate.category;
538
549
  existing.evidenceCount += candidate.evidence.length;
539
550
  existing.updatedAt = new Date().toISOString();
540
551
  result.updated++;
@@ -566,7 +577,8 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
566
577
  const entry = {
567
578
  id, fingerprint: existing?.fingerprint ?? fingerprint, contentHash: hash,
568
579
  title: candidate.title, summary: candidate.summary,
569
- keywords: candidate.keywords, scope: existing?.scope ?? candidate.scope, track, file: relativeFile,
580
+ keywords: candidate.keywords, category: candidate.category,
581
+ scope: existing?.scope ?? candidate.scope, track, file: relativeFile,
570
582
  evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
571
583
  identityKey: candidate.identityKey ?? existing?.identityKey,
572
584
  claimKeys: candidate.claims?.map((claim) => claim.key) ?? existing?.claimKeys,
@@ -43,7 +43,7 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
43
43
  maxExistingContextItems: 8,
44
44
  maxExistingBodyItems: 2,
45
45
  maxExistingBodyChars: 600,
46
- maxCandidates: 3,
46
+ maxCandidates: 2,
47
47
  minimumConfidence: 0.72,
48
48
  }),
49
49
  storage: Object.freeze({
@@ -208,6 +208,7 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
208
208
  title: entry.title,
209
209
  summary: entry.summary,
210
210
  keywords: entry.keywords,
211
+ category: entry.category,
211
212
  track: entry.track,
212
213
  identityKey: entry.identityKey,
213
214
  claimKeys: entry.claimKeys,
@@ -254,7 +255,6 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
254
255
  },
255
256
  {
256
257
  signal: controller.signal,
257
- reasoningEffort: "low",
258
258
  cacheRetention: "none",
259
259
  temperature: 0,
260
260
  timeoutMs: KNOWLEDGE_RUNTIME_DEFAULTS.review.requestTimeoutMs,
@@ -22,6 +22,7 @@ import {
22
22
  type ModelInput,
23
23
  type ModelProviderConfig,
24
24
  } from "../lib/models/provider-config.ts";
25
+ import { closeOpenAiSseAtDone } from "../lib/models/sse-response.ts";
25
26
 
26
27
  type OpenAIModel = Model<"openai-completions">;
27
28
 
@@ -373,8 +374,14 @@ function createLoginProvider(
373
374
  update: () => { models = refreshed; },
374
375
  });
375
376
  },
376
- stream: (model, context, options) => stream(model, context, options as Parameters<typeof stream>[2]),
377
- streamSimple: (model, context, options) => streamSimple(model, context, options),
377
+ stream: (model, context, options) => stream(model, context, {
378
+ ...options,
379
+ fetch: closeOpenAiSseAtDone(options?.fetch),
380
+ } as Parameters<typeof stream>[2]),
381
+ streamSimple: (model, context, options) => streamSimple(model, context, {
382
+ ...options,
383
+ fetch: closeOpenAiSseAtDone(options?.fetch),
384
+ }),
378
385
  };
379
386
  }
380
387
 
@@ -314,5 +314,5 @@ export function buildKnowledgeEpisodes(evidence: KnowledgeEvidence[]): Knowledge
314
314
  ? episodes.filter((episode) => !episode.id.includes("-general-reusable-work-") || episode.userSignal)
315
315
  : episodes;
316
316
  return retained.sort((left, right) => right.score - left.score || right.evidenceIds.length - left.evidenceIds.length)
317
- .slice(0, 6);
317
+ .slice(0, 2);
318
318
  }
@@ -2,13 +2,14 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
4
4
  import { compactKnowledgeText } from "./sanitize.ts";
5
- import type { KnowledgeEpisode, KnowledgeEpisodeDecision, KnowledgeEvidence } from "./types.ts";
5
+ import type { KnowledgeCategory, KnowledgeEpisode, KnowledgeEpisodeDecision, KnowledgeEvidence } from "./types.ts";
6
6
 
7
7
  export interface ExistingKnowledgeContext {
8
8
  id: string;
9
9
  title: string;
10
10
  summary: string;
11
11
  keywords: string[];
12
+ category?: KnowledgeCategory;
12
13
  track: "rule" | "topic";
13
14
  identityKey?: string;
14
15
  claimKeys?: string[];
@@ -33,14 +34,16 @@ Treat failed, unsupported, invalid, or corrected operations as negative evidence
33
34
  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.
34
35
  Rank knowledge by future time saved, repeated failures, hypothesis reversals, user corrections, verified resolution, reuse, and novelty. Do not favor the final task result merely because it completed the objective. Every supplied episode scoring 45 or higher must have an episodeDecisions entry, even when skipped.
35
36
  Relevant existing knowledge may be supplied. Cheap recall only proposes related items; it does not prove duplication. Compare execution environment, symptom, and root cause. Use action "reinforce" only when the exact target already covers every claim and there is no new content. Use "extend" for new compatible claims, "correct" when direct evidence disproves old content, "retire" when the whole target is invalid, and "add" for a distinct environment or root cause. Never turn lexical similarity into reinforcement.
36
- Prefer the most specific existing topic that already covers a claim over a broad conventions or SOP topic.
37
+ For every candidate, first compare the most specific recalled existing topic. When it covers the same concrete failure environment, symptom, root cause, or verified workaround, you must target that topic with "reinforce", "extend", or "correct" as warranted instead of adding knowledge. Prefer the concrete fault topic over broad conventions, generic SOP topics, or incidental access facts such as a temporary SSH user, port, or resource name.
37
38
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
38
39
  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.
39
40
  Every technical claim must have a stable claim key and cite evidenceIds from the supplied evidence ledger. Tool success, tool failure, and verification evidence outrank assistant narration. Do not support a service-wide or universal claim with one operation-specific failure. Evidence for an unrelated parameter error cannot support an API-version claim.
40
- Keep each candidate atomic: put separable claim sets in separate candidates, omit any claim without direct cited evidence, and prioritize the highest-value supported candidates. One unsupported claim rejects its entire candidate.
41
- 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.
41
+ Keep each candidate atomic: put separable claim sets in separate candidates, omit any claim without direct cited evidence, and select only the two highest-value supported information items. One unsupported claim rejects its entire candidate.
42
+ Classify every candidate as exactly one category: "principle" for a durable invariant or rule; "configuration" for a verified setting or parameter contract; "method" for a repeatable procedure; "strategy" for reusable diagnosis or design reasoning; or "caution" for a trap, failure signature, or boundary.
43
+ Return compact JSON intended for a background task: at most 2 candidates; each summary at most 120 characters; each topic body at most 800 characters; each episode decision reason at most 120 characters; and all candidate bodies combined at most 1400 characters. Prefer fewer complete high-value candidates over filling the limit. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered.
42
44
  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.
43
- Return: {"episodeDecisions":[{"episodeId":"ep-id","decision":"add|reinforce|extend|correct|retire|skip","targetId":"existing-id or omitted","reason":"..."}],"candidates":[{"key":"stable semantic key","identityKey":"platform/runtime/problem/root-cause","targetId":"existing-id or null","episodeIds":["ep-id"],"title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body or only the new section for extend","claims":[{"key":"stable claim key","text":"one precise claim","evidenceIds":["ev-id"]}],"evidenceIds":["ev-id"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|extend|correct|retire","explicitUserDirective":false,"durability":"stable"}]}
45
+ Evidence IDs are short handles such as "e1" and "e2". Copy them exactly from the evidence ledger.
46
+ Return: {"episodeDecisions":[{"episodeId":"ep-id","decision":"add|reinforce|extend|correct|retire|skip","targetId":"existing-id or omitted","reason":"..."}],"candidates":[{"key":"stable semantic key","category":"principle|configuration|method|strategy|caution","identityKey":"platform/runtime/problem/root-cause","targetId":"existing-id or null","episodeIds":["ep-id"],"title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body or only the new section for extend","claims":[{"key":"stable claim key","text":"one precise claim","evidenceIds":["e1"]}],"evidenceIds":["e1"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|extend|correct|retire","explicitUserDirective":false,"durability":"stable"}]}
44
47
  Return {"episodeDecisions":[],"candidates":[]} only when there are no mandatory episodes and nothing meets the threshold.`;
45
48
 
46
49
  export function buildKnowledgeExtractionPrompt(
@@ -283,7 +283,9 @@ export function readReviewTask(
283
283
  if (pending.length === 0) return undefined;
284
284
  const pendingEntryIds = new Set(pending.map((entry) => entry.id));
285
285
  const evidenceWindow = cursorIndex >= 0 ? branch.slice(Math.max(0, cursorIndex - 40)) : pending;
286
- const evidence = buildKnowledgeEvidence(evidenceWindow).filter((item) => pendingEntryIds.has(item.entryId));
286
+ const evidence = buildKnowledgeEvidence(evidenceWindow)
287
+ .filter((item) => pendingEntryIds.has(item.entryId))
288
+ .map((item, index) => ({ ...item, id: `e${index + 1}` }));
287
289
  const episodes = buildKnowledgeEpisodes(evidence);
288
290
  const lookupResultEntryIds = knowledgeLookupResultEntryIds(branch);
289
291
  const delta = buildDelta(pending, lookupResultEntryIds);
@@ -10,7 +10,7 @@ import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
10
10
  import { userRuntimePaths } from "../runtime/paths.ts";
11
11
  import { compactKnowledgeSummary, compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
12
12
  import type {
13
- KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeClaim, KnowledgeEpisodeDecision,
13
+ KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeCategory, KnowledgeClaim, KnowledgeEpisodeDecision,
14
14
  KnowledgeEvidence, KnowledgeManifest, KnowledgeReviewCursor, KnowledgeReviewTask, KnowledgeScope,
15
15
  KnowledgeSkipReason, KnowledgeSnapshot, KnowledgeTrack,
16
16
  PersistKnowledgeResult,
@@ -22,6 +22,9 @@ const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
22
22
  const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
23
23
  const UNVERIFIED_CLAIM_RE = /(?:待验证|尚未(?:验证|确认)|未经(?:验证|确认)|未(?:经)?证实|推测|猜测|疑似|可能是|可能由于|可能导致|或许|unverified|unconfirmed|speculat(?:e|ive|ion)|hypothes(?:is|ize)|suspect(?:ed|ion)?|possibly|probably)/iu;
24
24
  const VOLATILE_ARGUMENT_RE = /--(?:namespace|name|server_name|security_group_id|subnet_id|vpc_id|image_id|project_id)(?:\.\d+)?=(?!<[^>]+>|\$?\{)[^\s`"']+/iu;
25
+ const KNOWLEDGE_CATEGORIES = new Set<KnowledgeCategory>([
26
+ "principle", "configuration", "method", "strategy", "caution",
27
+ ]);
25
28
 
26
29
  export class KnowledgeCommitBusyError extends Error {}
27
30
 
@@ -248,6 +251,9 @@ function normalizeCandidate(
248
251
  const raw = value as Record<string, unknown>;
249
252
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
250
253
  || typeof raw.body !== "string" || typeof raw.confidence !== "number") return { reason: "invalid-schema" };
254
+ if (typeof raw.category !== "string" || !KNOWLEDGE_CATEGORIES.has(raw.category as KnowledgeCategory)) {
255
+ return { reason: "invalid-schema" };
256
+ }
251
257
  if (raw.durability !== "stable") return { reason: "unstable" };
252
258
  if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return { reason: "low-confidence" };
253
259
  if (raw.targetId !== undefined && raw.targetId !== null
@@ -301,6 +307,7 @@ function normalizeCandidate(
301
307
  || raw.action === "retire" ? raw.action : "add";
302
308
  return { candidate: {
303
309
  key: compactKnowledgeText(raw.key, 160),
310
+ category: raw.category as KnowledgeCategory,
304
311
  identityKey: typeof raw.identityKey === "string" ? compactKnowledgeText(raw.identityKey, 240).toLowerCase() : undefined,
305
312
  targetId,
306
313
  title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
@@ -330,7 +337,8 @@ function writeRejectedPending(directory: string, reason: KnowledgeSkipReason, va
330
337
  function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
331
338
  if (candidate.storageHint === "rule") return `# ${candidate.title}\n\n${candidate.body}\n`;
332
339
  return [
333
- `# ${candidate.title}`, "", candidate.summary, "", `Keywords: ${candidate.keywords.join(", ")}`, "",
340
+ `# ${candidate.title}`, "", candidate.summary, "", `Category: ${candidate.category}`,
341
+ `Keywords: ${candidate.keywords.join(", ")}`, "",
334
342
  candidate.body, "", "## Evidence", "", ...candidate.evidence.map((item) => `- ${item}`), "",
335
343
  ].join("\n");
336
344
  }
@@ -359,7 +367,8 @@ function generateMemory(catalog: KnowledgeCatalog, projectKey?: string): string
359
367
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
360
368
  for (const item of topics) {
361
369
  const scope = item.scope === "global" ? "global" : "project";
362
- const line = `- [${item.id}] (${scope}; ${item.keywords.join(", ")}) ${item.title}: ${item.summary}`;
370
+ const category = item.category ? `${item.category}; ` : "";
371
+ const line = `- [${item.id}] (${category}${scope}; ${item.keywords.join(", ")}) ${item.title}: ${item.summary}`;
363
372
  if (lines.length + 1 >= STORAGE.maxMemoryLines || [...lines, line].join("\n").length > STORAGE.maxMemoryChars) {
364
373
  lines.push("- Additional topics remain searchable through hwcode_knowledge_lookup(query: \"keywords\").");
365
374
  break;
@@ -509,6 +518,7 @@ export function commitKnowledgeReview(
509
518
  continue;
510
519
  }
511
520
  existing.evidenceCount += candidate.evidence.length;
521
+ existing.category ??= candidate.category;
512
522
  existing.updatedAt = new Date().toISOString();
513
523
  result.updated++;
514
524
  continue;
@@ -552,6 +562,7 @@ export function commitKnowledgeReview(
552
562
  existing.contentHash = createHash("sha256").update(content).digest("hex");
553
563
  existing.keywords = [...new Set([...existing.keywords, ...candidate.keywords])].slice(0, STORAGE.maxKeywordCount);
554
564
  existing.claimKeys = [...new Set([...(existing.claimKeys ?? []), ...newClaimKeys])];
565
+ existing.category ??= candidate.category;
555
566
  existing.evidenceCount += candidate.evidence.length;
556
567
  existing.updatedAt = new Date().toISOString();
557
568
  result.updated++;
@@ -584,7 +595,8 @@ export function commitKnowledgeReview(
584
595
  const entry: KnowledgeCatalogEntry = {
585
596
  id, fingerprint: existing?.fingerprint ?? fingerprint, contentHash: hash,
586
597
  title: candidate.title, summary: candidate.summary,
587
- keywords: candidate.keywords, scope: existing?.scope ?? candidate.scope, track, file: relativeFile,
598
+ keywords: candidate.keywords, category: candidate.category,
599
+ scope: existing?.scope ?? candidate.scope, track, file: relativeFile,
588
600
  evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
589
601
  identityKey: candidate.identityKey ?? existing?.identityKey,
590
602
  claimKeys: candidate.claims?.map((claim) => claim.key) ?? existing?.claimKeys,
@@ -1,4 +1,5 @@
1
1
  export type KnowledgeTrack = "rule" | "topic";
2
+ export type KnowledgeCategory = "principle" | "configuration" | "method" | "strategy" | "caution";
2
3
  export type KnowledgeScope = "global" | `project:${string}`;
3
4
  export type KnowledgeAction = "add" | "reinforce" | "extend" | "correct" | "retire";
4
5
  export type KnowledgeDurability = "stable";
@@ -50,6 +51,7 @@ export interface KnowledgeEpisodeDecision {
50
51
 
51
52
  export interface KnowledgeCandidate {
52
53
  key: string;
54
+ category: KnowledgeCategory;
53
55
  identityKey?: string;
54
56
  targetId?: string;
55
57
  title: string;
@@ -75,6 +77,7 @@ export interface KnowledgeCatalogEntry {
75
77
  title: string;
76
78
  summary: string;
77
79
  keywords: string[];
80
+ category?: KnowledgeCategory;
78
81
  scope: KnowledgeScope;
79
82
  track: KnowledgeTrack;
80
83
  file: string;
@@ -0,0 +1,50 @@
1
+ const SSE_CONTENT_TYPE = "text/event-stream";
2
+ const DONE_EVENT_RE = /(?:^|\r?\n)data:\s*\[DONE\]\s*(?:\r?\n|$)/u;
3
+ const MARKER_TAIL_CHARS = 64;
4
+
5
+ function closeAtDone(response: Response): Response {
6
+ if (!response.body || !response.headers.get("content-type")?.toLowerCase().includes(SSE_CONTENT_TYPE)) {
7
+ return response;
8
+ }
9
+
10
+ const reader = response.body.getReader();
11
+ const decoder = new TextDecoder();
12
+ let tail = "";
13
+ let closed = false;
14
+ const body = new ReadableStream<Uint8Array>({
15
+ async pull(controller) {
16
+ try {
17
+ const chunk = await reader.read();
18
+ if (chunk.done) {
19
+ closed = true;
20
+ controller.close();
21
+ return;
22
+ }
23
+ controller.enqueue(chunk.value);
24
+ const text = `${tail}${decoder.decode(chunk.value, { stream: true })}`;
25
+ tail = text.slice(-MARKER_TAIL_CHARS);
26
+ if (!DONE_EVENT_RE.test(text)) return;
27
+ closed = true;
28
+ controller.close();
29
+ void reader.cancel("OpenAI SSE completed with [DONE]").catch(() => {});
30
+ } catch (error) {
31
+ closed = true;
32
+ controller.error(error);
33
+ }
34
+ },
35
+ cancel(reason) {
36
+ if (closed) return;
37
+ closed = true;
38
+ return reader.cancel(reason);
39
+ },
40
+ });
41
+ return new Response(body, {
42
+ status: response.status,
43
+ statusText: response.statusText,
44
+ headers: response.headers,
45
+ });
46
+ }
47
+
48
+ export function closeOpenAiSseAtDone(fetchImplementation: typeof fetch = globalThis.fetch): typeof fetch {
49
+ return (async (input, init) => closeAtDone(await fetchImplementation(input, init))) as typeof fetch;
50
+ }
@@ -44,7 +44,7 @@ export const KNOWLEDGE_RUNTIME_DEFAULTS = Object.freeze({
44
44
  maxExistingContextItems: 8,
45
45
  maxExistingBodyItems: 2,
46
46
  maxExistingBodyChars: 600,
47
- maxCandidates: 3,
47
+ maxCandidates: 2,
48
48
  minimumConfidence: 0.72,
49
49
  }),
50
50
  storage: Object.freeze({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {