@hadooppei/hwcode 1.0.11 → 1.0.13

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.
@@ -11,7 +11,8 @@ import { userRuntimePaths } from "../runtime/paths.ts";
11
11
  import { compactKnowledgeSummary, compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
12
12
  import type {
13
13
  KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeManifest, KnowledgeReviewCursor,
14
- KnowledgeReviewTask, KnowledgeScope, KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
14
+ KnowledgeReviewTask, KnowledgeScope, KnowledgeSkipReason, KnowledgeSnapshot, KnowledgeTrack,
15
+ PersistKnowledgeResult,
15
16
  } from "./types.ts";
16
17
 
17
18
  const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
@@ -117,6 +118,67 @@ function normalizedFingerprint(candidate: KnowledgeCandidate): string {
117
118
  return createHash("sha256").update(`${candidate.scope}\0${candidate.key.trim().toLowerCase()}`).digest("hex");
118
119
  }
119
120
 
121
+ function comparisonTerms(value: string): Set<string> {
122
+ return new Set((value.normalize("NFKC").toLowerCase().match(/\p{Script=Han}+|[\p{L}\p{N}]+/gu) ?? [])
123
+ .filter((term) => term.length >= 2));
124
+ }
125
+
126
+ function containment(left: Set<string>, right: Set<string>): number {
127
+ const minimum = Math.min(left.size, right.size);
128
+ if (minimum === 0) return 0;
129
+ let shared = 0;
130
+ for (const term of left) if (right.has(term)) shared++;
131
+ return shared / minimum;
132
+ }
133
+
134
+ function semanticDuplicateScore(candidate: KnowledgeCandidate, entry: KnowledgeCatalogEntry): number {
135
+ if (candidate.scope !== entry.scope || candidate.storageHint !== entry.track) return 0;
136
+ const titleScore = containment(comparisonTerms(candidate.title), comparisonTerms(entry.title));
137
+ const keywordScore = containment(
138
+ comparisonTerms(candidate.keywords.join(" ")),
139
+ comparisonTerms(entry.keywords.join(" ")),
140
+ );
141
+ if (titleScore < 0.8 || keywordScore < 0.5) return 0;
142
+ return titleScore * 0.7 + keywordScore * 0.3;
143
+ }
144
+
145
+ function semanticDuplicateIndex(candidate: KnowledgeCandidate, catalog: KnowledgeCatalog): number {
146
+ let bestIndex = -1;
147
+ let bestScore = 0;
148
+ for (const [index, entry] of catalog.items.entries()) {
149
+ const score = semanticDuplicateScore(candidate, entry);
150
+ if (score > bestScore) { bestIndex = index; bestScore = score; }
151
+ }
152
+ return bestIndex;
153
+ }
154
+
155
+ interface UnsupportedOperation {
156
+ operation: string;
157
+ service?: string;
158
+ }
159
+
160
+ function unsupportedOperations(value: string): UnsupportedOperation[] {
161
+ return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)].map((match) => {
162
+ const suffix = value.slice(match.index + match[0].length, match.index + match[0].length + 500);
163
+ const service = /\bhcloud\s+([A-Za-z][A-Za-z0-9-]*)\s+--help\b/iu.exec(suffix)?.[1]?.toLowerCase();
164
+ return { operation: match[1]!.toLowerCase(), service };
165
+ });
166
+ }
167
+
168
+ function contradictsVerifiedFailures(body: string, validationText: string): boolean {
169
+ const normalizedBody = body.toLowerCase();
170
+ for (const unsupported of unsupportedOperations(validationText)) {
171
+ const operationExpression = unsupported.service
172
+ ? new RegExp(`\\b(?:hcloud\\s+)?${unsupported.service}\\b[^\\n]{0,120}\\b${unsupported.operation}\\b`, "u")
173
+ : new RegExp(`\\b${unsupported.operation}\\b`, "u");
174
+ const index = normalizedBody.search(operationExpression);
175
+ if (index < 0) continue;
176
+ const context = normalizedBody.slice(Math.max(0, index - 80), index + 240);
177
+ if (!/(?:not supported|unsupported|do not|don't|avoid|不支持|不要|避免)/u.test(context)) return true;
178
+ }
179
+ return false;
180
+ }
181
+
120
182
  function contentHash(candidate: KnowledgeCandidate): string {
121
183
  return createHash("sha256").update(`${candidate.title}\0${candidate.summary}\0${candidate.body}`).digest("hex");
122
184
  }
@@ -127,30 +189,52 @@ function asStringArray(value: unknown, maxItems: number): string[] {
127
189
  .map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
128
190
  }
129
191
 
130
- function normalizeCandidate(value: unknown, projectKey: string): KnowledgeCandidate | undefined {
131
- if (!value || typeof value !== "object") return undefined;
192
+ type CandidateNormalization = { candidate: KnowledgeCandidate } | { reason: KnowledgeSkipReason };
193
+
194
+ function normalizeCandidate(value: unknown, projectKey: string, validationText: string): CandidateNormalization {
195
+ if (!value || typeof value !== "object") return { reason: "invalid-schema" };
132
196
  const raw = value as Record<string, unknown>;
133
197
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
134
- || typeof raw.body !== "string" || typeof raw.confidence !== "number") return undefined;
135
- if (raw.durability !== "stable") return undefined;
136
- if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return undefined;
198
+ || typeof raw.body !== "string" || typeof raw.confidence !== "number") return { reason: "invalid-schema" };
199
+ if (raw.durability !== "stable") return { reason: "unstable" };
200
+ if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return { reason: "low-confidence" };
201
+ if (raw.targetId !== undefined && raw.targetId !== null
202
+ && (typeof raw.targetId !== "string" || !SAFE_ID_RE.test(raw.targetId))) return { reason: "unknown-target" };
137
203
  const explicitUserDirective = raw.explicitUserDirective === true;
138
204
  const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
139
205
  const body = compactKnowledgeText(raw.body, 20_000);
140
206
  const evidence = asStringArray(raw.evidence, 8);
141
- if (!body || evidence.length === 0) return undefined;
207
+ if (!body || evidence.length === 0) return { reason: "missing-body-or-evidence" };
208
+ if (contradictsVerifiedFailures(body, validationText)) return { reason: "contradicts-verified-failure" };
142
209
  const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
143
210
  const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
211
+ && body.split(/\n\s*\n/gu).length === 1
144
212
  && (explicitUserDirective || raw.confidence >= 0.9);
145
- return {
146
- key: compactKnowledgeText(raw.key, 160), title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
213
+ const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
214
+ return { candidate: {
215
+ key: compactKnowledgeText(raw.key, 160), targetId,
216
+ title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
147
217
  summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
148
218
  keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
149
219
  scope, body, evidence, confidence: Math.min(1, raw.confidence),
150
220
  storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
151
221
  action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
152
222
  explicitUserDirective, durability: "stable",
153
- };
223
+ } };
224
+ }
225
+
226
+ function recordSkip(result: PersistKnowledgeResult, reason: KnowledgeSkipReason): void {
227
+ result.skipped++;
228
+ result.skippedReasons ??= {};
229
+ result.skippedReasons[reason] = (result.skippedReasons[reason] ?? 0) + 1;
230
+ }
231
+
232
+ function writeRejectedPending(directory: string, reason: KnowledgeSkipReason, value: unknown): void {
233
+ let serialized: string;
234
+ try { serialized = JSON.stringify(value); } catch { serialized = String(value); }
235
+ const candidate = sanitizeKnowledgeText(serialized).slice(0, 10_000);
236
+ const name = `${Date.now()}-rejected-${reason}-${randomUUID().slice(0, 8)}.json`;
237
+ atomicWrite(join(directory, "pending", name), `${JSON.stringify({ reason, candidate }, null, 2)}\n`);
154
238
  }
155
239
 
156
240
  function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
@@ -274,14 +358,35 @@ export function commitKnowledgeReview(
274
358
  copyCurrentContent(current, temporary, home);
275
359
  const catalog: KnowledgeCatalog = structuredClone(current.catalog);
276
360
  const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
361
+ const rejected: Array<{ reason: KnowledgeSkipReason; value: unknown }> = [];
277
362
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
278
- const candidate = normalizeCandidate(value, task.projectKey);
279
- if (!candidate) { result.skipped++; continue; }
363
+ const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
364
+ if (!("candidate" in normalized)) {
365
+ recordSkip(result, normalized.reason);
366
+ rejected.push({ reason: normalized.reason, value });
367
+ continue;
368
+ }
369
+ const candidate = normalized.candidate;
280
370
  const fingerprint = normalizedFingerprint(candidate);
281
371
  const hash = contentHash(candidate);
282
- const existingIndex = catalog.items.findIndex((item) => item.fingerprint === fingerprint);
372
+ const targetIndex = candidate.targetId
373
+ ? catalog.items.findIndex((item) => item.id === candidate.targetId && applicable(item, task.projectKey))
374
+ : -1;
375
+ if (candidate.targetId && targetIndex < 0) {
376
+ recordSkip(result, "unknown-target");
377
+ rejected.push({ reason: "unknown-target", value });
378
+ continue;
379
+ }
380
+ let existingIndex = targetIndex >= 0
381
+ ? targetIndex
382
+ : catalog.items.findIndex((item) => item.fingerprint === fingerprint);
383
+ let semanticReinforcement = false;
384
+ if (existingIndex < 0) {
385
+ existingIndex = semanticDuplicateIndex(candidate, catalog);
386
+ semanticReinforcement = existingIndex >= 0;
387
+ }
283
388
  const existing = existingIndex >= 0 ? catalog.items[existingIndex] : undefined;
284
- if (existing?.contentHash === hash) {
389
+ if (existing && (existing.contentHash === hash || candidate.action === "reinforce" || semanticReinforcement)) {
285
390
  existing.evidenceCount += candidate.evidence.length;
286
391
  existing.updatedAt = new Date().toISOString();
287
392
  result.updated++;
@@ -304,14 +409,19 @@ export function commitKnowledgeReview(
304
409
  const relativeFile = `${track === "rule" ? "rules" : "topics"}/${id}.md`;
305
410
  atomicWrite(join(temporary, relativeFile), renderKnowledgeFile(candidate));
306
411
  const entry: KnowledgeCatalogEntry = {
307
- id, fingerprint, contentHash: hash, title: candidate.title, summary: candidate.summary,
308
- keywords: candidate.keywords, scope: candidate.scope, track, file: relativeFile,
412
+ id, fingerprint: existing?.fingerprint ?? fingerprint, contentHash: hash,
413
+ title: candidate.title, summary: candidate.summary,
414
+ keywords: candidate.keywords, scope: existing?.scope ?? candidate.scope, track, file: relativeFile,
309
415
  evidenceCount: (existing?.evidenceCount ?? 0) + candidate.evidence.length,
310
416
  createdAt: existing?.createdAt ?? now, updatedAt: now,
311
417
  };
312
418
  if (existingIndex >= 0) { catalog.items[existingIndex] = entry; result.updated++; }
313
419
  else { catalog.items.push(entry); result.saved++; }
314
420
  }
421
+ if (result.skipped > 0 && result.saved === 0 && result.updated === 0 && result.pending === 0) {
422
+ for (const item of rejected) writeRejectedPending(temporary, item.reason, item.value);
423
+ result.pending += rejected.length;
424
+ }
315
425
  catalog.updatedAt = new Date().toISOString();
316
426
  const manifest: KnowledgeManifest = {
317
427
  version: 3, generationId, createdAt: new Date().toISOString(), leaderToken, catalog,
@@ -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[];
@@ -74,8 +75,17 @@ export interface PersistKnowledgeResult {
74
75
  updated: number;
75
76
  pending: number;
76
77
  skipped: number;
78
+ skippedReasons?: Partial<Record<KnowledgeSkipReason, number>>;
77
79
  }
78
80
 
81
+ export type KnowledgeSkipReason =
82
+ | "invalid-schema"
83
+ | "unstable"
84
+ | "low-confidence"
85
+ | "missing-body-or-evidence"
86
+ | "contradicts-verified-failure"
87
+ | "unknown-target";
88
+
79
89
  export interface KnowledgeReviewTask {
80
90
  requestId: string;
81
91
  reviewKey: string;
@@ -86,6 +96,7 @@ export interface KnowledgeReviewTask {
86
96
  projectKey: string;
87
97
  firstEntryId: string;
88
98
  lastEntryId: string;
99
+ context?: string;
89
100
  delta: string;
90
101
  deltaDigest: string;
91
102
  fileSize: number;
@@ -2,6 +2,7 @@ import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
2
2
 
3
3
  export type KnowledgeWorkerInput =
4
4
  | { type: "configure"; modelAvailable: boolean; sessionsRoot: string }
5
+ | { type: "review_deferred"; leaderToken: string; requestId: string; reason: string }
5
6
  | { type: "review_result"; leaderToken: string; requestId: string; raw?: string; error?: string }
6
7
  | { type: "scan_now" }
7
8
  | { type: "stop" };
@@ -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,
@@ -189,6 +189,16 @@ export function getActiveWorkflowRoot(entries: readonly SessionEntry[]): string
189
189
  return activeWorkflow(entries)?.root;
190
190
  }
191
191
 
192
+ export function findLatestWorkflowRoot(entries: readonly SessionEntry[]): string | undefined {
193
+ for (const entry of [...entries].reverse()) {
194
+ if (entry.type !== "custom" || entry.customType !== "hwcode-workflow-state"
195
+ || !entry.data || typeof entry.data !== "object") continue;
196
+ const root = (entry.data as Record<string, unknown>).root;
197
+ if (typeof root === "string" && root) return root;
198
+ }
199
+ return undefined;
200
+ }
201
+
192
202
  export function shellQuote(value: string): string {
193
203
  return `'${value.replaceAll("'", `'"'"'`)}'`;
194
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {