@hadooppei/hwcode 1.0.12 → 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.
@@ -0,0 +1,170 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { activeWorkflow } from "./workflows/state.js";
5
+ export const WORKING_DIRECTORY_STATE_TYPE = "hwcode-working-directory";
6
+ const workingDirectories = new Map();
7
+ function isWorkingDirectoryState(value) {
8
+ if (!value || typeof value !== "object")
9
+ return false;
10
+ const data = value;
11
+ return data.version === 1
12
+ && typeof data.cwd === "string"
13
+ && (data.previousCwd === undefined || typeof data.previousCwd === "string");
14
+ }
15
+ export function findPersistedWorkingDirectory(entries) {
16
+ for (const entry of [...entries].reverse()) {
17
+ if (entry.type !== "custom" || entry.customType !== WORKING_DIRECTORY_STATE_TYPE)
18
+ continue;
19
+ if (isWorkingDirectoryState(entry.data))
20
+ return entry.data;
21
+ }
22
+ return undefined;
23
+ }
24
+ export function canonicalizeDirectory(path) {
25
+ const canonical = realpathSync(path);
26
+ if (!statSync(canonical).isDirectory())
27
+ throw new Error(`Not a directory: ${path}`);
28
+ return canonical;
29
+ }
30
+ function initialState(source) {
31
+ const persisted = findPersistedWorkingDirectory(source.getEntries());
32
+ if (persisted)
33
+ return { ...persisted, cwd: canonicalizeDirectory(persisted.cwd) };
34
+ return { version: 1, cwd: canonicalizeDirectory(source.getCwd()) };
35
+ }
36
+ export function getWorkingDirectoryState(source) {
37
+ return workingDirectories.get(source.getSessionId()) ?? initialState(source);
38
+ }
39
+ export function getWorkingDirectory(source) {
40
+ return getWorkingDirectoryState(source).cwd;
41
+ }
42
+ export function setWorkingDirectoryState(source, state) {
43
+ workingDirectories.set(source.getSessionId(), state);
44
+ }
45
+ export function resetWorkingDirectoryState(source) {
46
+ const state = initialState(source);
47
+ setWorkingDirectoryState(source, state);
48
+ return state;
49
+ }
50
+ export function clearWorkingDirectoryState(source) {
51
+ workingDirectories.delete(source.getSessionId());
52
+ }
53
+ function expandHome(path, home) {
54
+ if (path === "~" || path === "$HOME" || path === "${HOME}")
55
+ return home;
56
+ if (path.startsWith("~/"))
57
+ return resolve(home, path.slice(2));
58
+ if (path.startsWith("$HOME/"))
59
+ return resolve(home, path.slice(6));
60
+ if (path.startsWith("${HOME}/"))
61
+ return resolve(home, path.slice(8));
62
+ return path;
63
+ }
64
+ export function resolveDirectoryArgument(base, argument, previousCwd, home = homedir()) {
65
+ if (argument === "-") {
66
+ if (!previousCwd)
67
+ throw new Error("No previous working directory is available.");
68
+ return canonicalizeDirectory(previousCwd);
69
+ }
70
+ const expanded = expandHome(argument || home, home);
71
+ return canonicalizeDirectory(isAbsolute(expanded) ? expanded : resolve(base, expanded));
72
+ }
73
+ export function resolveWorkingPath(base, path, home = homedir()) {
74
+ const expanded = expandHome(path, home);
75
+ return isAbsolute(expanded) ? expanded : resolve(base, expanded);
76
+ }
77
+ function readShellWord(command, start) {
78
+ let value = "";
79
+ let quote;
80
+ let index = start;
81
+ for (; index < command.length; index += 1) {
82
+ const character = command[index];
83
+ if (quote) {
84
+ if (character === quote) {
85
+ quote = undefined;
86
+ continue;
87
+ }
88
+ if (character === "\\" && quote === '"' && index + 1 < command.length) {
89
+ index += 1;
90
+ value += command[index];
91
+ continue;
92
+ }
93
+ value += character;
94
+ continue;
95
+ }
96
+ if (character === "'" || character === '"') {
97
+ quote = character;
98
+ continue;
99
+ }
100
+ if (character === "\\" && index + 1 < command.length) {
101
+ index += 1;
102
+ value += command[index];
103
+ continue;
104
+ }
105
+ if (/\s/u.test(character) || character === ";" || character === "&")
106
+ break;
107
+ if ("|<>`".includes(character) || character === "$" && command[index + 1] === "(")
108
+ return undefined;
109
+ value += character;
110
+ }
111
+ if (quote || value.length === 0)
112
+ return undefined;
113
+ return { value, end: index };
114
+ }
115
+ /** Parse a leading persistent `cd`, optionally followed by `&&` or `;`. */
116
+ export function parseLeadingDirectoryChange(command) {
117
+ let index = 0;
118
+ while (/\s/u.test(command[index] ?? ""))
119
+ index += 1;
120
+ if (command.slice(index, index + 2) !== "cd")
121
+ return undefined;
122
+ const next = command[index + 2];
123
+ if (next && !/\s/u.test(next) && next !== ";" && command.slice(index + 2, index + 4) !== "&&") {
124
+ return undefined;
125
+ }
126
+ index += 2;
127
+ while (/\s/u.test(command[index] ?? ""))
128
+ index += 1;
129
+ if (command.slice(index, index + 2) === "--") {
130
+ index += 2;
131
+ while (/\s/u.test(command[index] ?? ""))
132
+ index += 1;
133
+ }
134
+ let argument = "";
135
+ if (index < command.length && command[index] !== ";" && command.slice(index, index + 2) !== "&&") {
136
+ const word = readShellWord(command, index);
137
+ if (!word)
138
+ return undefined;
139
+ argument = word.value;
140
+ index = word.end;
141
+ }
142
+ while (/\s/u.test(command[index] ?? ""))
143
+ index += 1;
144
+ if (index >= command.length)
145
+ return { argument, remainder: "" };
146
+ if (command.slice(index, index + 2) === "&&")
147
+ index += 2;
148
+ else if (command[index] === ";")
149
+ index += 1;
150
+ else
151
+ return undefined;
152
+ return { argument, remainder: command.slice(index).trimStart() };
153
+ }
154
+ export function getActiveWorkflowRoot(entries) {
155
+ return activeWorkflow(entries)?.root;
156
+ }
157
+ export function findLatestWorkflowRoot(entries) {
158
+ for (const entry of [...entries].reverse()) {
159
+ if (entry.type !== "custom" || entry.customType !== "hwcode-workflow-state"
160
+ || !entry.data || typeof entry.data !== "object")
161
+ continue;
162
+ const root = entry.data.root;
163
+ if (typeof root === "string" && root)
164
+ return root;
165
+ }
166
+ return undefined;
167
+ }
168
+ export function shellQuote(value) {
169
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
170
+ }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { dirname } from "node:path";
3
+ import { dirname, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Worker } from "node:worker_threads";
6
6
 
@@ -10,16 +10,17 @@ 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";
17
- import { getWorkingDirectory } from "../lib/working-directory.ts";
17
+ import { findLatestWorkflowRoot, getWorkingDirectory } from "../lib/working-directory.ts";
18
18
 
19
19
  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,18 +105,39 @@ 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");
101
- if (!ctx.isIdle() || ctx.hasPendingMessages()) throw new Error("model-executor-is-busy");
112
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
113
+ post({
114
+ type: "review_deferred",
115
+ leaderToken: message.leaderToken,
116
+ requestId: message.requestId,
117
+ reason: "model-executor-is-busy",
118
+ });
119
+ return;
120
+ }
102
121
  const controller = new AbortController();
103
- runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
104
- 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);
105
129
  timeout.unref();
106
130
  const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
107
- const related: ExistingKnowledgeContext[] = matchKnowledge(
108
- message.task.delta,
109
- snapshot.catalog,
131
+ const applicableCatalog = {
132
+ ...snapshot.catalog,
133
+ items: snapshot.catalog.items.filter((entry) => (
134
+ entry.scope === "global" || entry.scope === `project:${message.task.projectKey}`
135
+ )),
136
+ };
137
+ const related: ExistingKnowledgeContext[] = recallKnowledge(
138
+ message.task.recallQuery || message.task.delta,
139
+ applicableCatalog,
140
+ message.task.loadedKnowledgeIds ?? [],
110
141
  KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
111
142
  ).map((entry, index) => {
112
143
  const reference: ExistingKnowledgeContext = {
@@ -141,15 +172,19 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
141
172
  },
142
173
  { signal: controller.signal, reasoningEffort: "low", cacheRetention: "none", sessionId: randomUUID() },
143
174
  );
144
- 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");
145
176
  const raw = response.content.filter((item): item is { type: "text"; text: string } => item.type === "text")
146
177
  .map((item) => item.text).join("\n");
147
178
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
148
179
  } catch (error) {
149
- post({
150
- type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
151
- error: error instanceof Error ? error.message : String(error),
152
- });
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)) {
184
+ post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
185
+ } else {
186
+ post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
187
+ }
153
188
  try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
154
189
  } finally {
155
190
  clearTimeout(timeout);
@@ -165,7 +200,7 @@ function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
165
200
  }
166
201
  if (message.type === "review_request") { void runReview(message); return; }
167
202
  if (message.type === "review_cancel" && runtime.activeReview?.requestId === message.requestId) {
168
- runtime.activeReview.controller.abort();
203
+ cancelActiveReview(message.reason);
169
204
  return;
170
205
  }
171
206
  if (message.type === "review_failed") reportWorkerIssue(message.error);
@@ -180,7 +215,7 @@ function ensureWorker(): Worker | undefined {
180
215
  worker.on("message", (message: KnowledgeWorkerOutput) => handleWorkerMessage(message));
181
216
  worker.on("error", (error) => {
182
217
  reportWorkerIssue(error);
183
- cancelActiveReview();
218
+ cancelActiveReview("knowledge-worker-restarting");
184
219
  if (runtime.worker === worker) {
185
220
  runtime.worker = undefined;
186
221
  runtime.modelAvailable = undefined;
@@ -210,7 +245,13 @@ function configureForContext(ctx: ExtensionContext): void {
210
245
  }
211
246
 
212
247
  function currentProject(ctx: ExtensionContext): { root: string; key: string } {
213
- const root = getWorkingDirectory(ctx.sessionManager);
248
+ const workingDirectory = resolve(getWorkingDirectory(ctx.sessionManager));
249
+ const workflowRootValue = findLatestWorkflowRoot(ctx.sessionManager.getEntries());
250
+ const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
251
+ const root = workflowRoot
252
+ && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))
253
+ ? workflowRoot
254
+ : workingDirectory;
214
255
  return { root, key: projectKnowledgeKey(root) };
215
256
  }
216
257
 
@@ -270,14 +311,14 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
270
311
 
271
312
  pi.on("input", (_event, ctx) => {
272
313
  runtime.context = ctx;
273
- cancelActiveReview();
314
+ cancelActiveReview("foreground-model-became-busy");
274
315
  updateCapability();
275
316
  return undefined;
276
317
  });
277
318
 
278
319
  pi.on("agent_start", (_event, ctx) => {
279
320
  runtime.context = ctx;
280
- cancelActiveReview();
321
+ cancelActiveReview("foreground-model-became-busy");
281
322
  });
282
323
 
283
324
  pi.on("agent_settled", (_event, ctx) => {
@@ -287,7 +328,7 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
287
328
  });
288
329
 
289
330
  pi.on("session_shutdown", (event) => {
290
- cancelActiveReview();
331
+ cancelActiveReview("session-shutdown");
291
332
  if (event.reason === "quit") {
292
333
  runtime.context = undefined;
293
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
+ }
@@ -23,6 +23,12 @@ export interface KnowledgeReviewStatus {
23
23
  attempt: number;
24
24
  startedAt: string;
25
25
  };
26
+ lastDeferred?: {
27
+ reviewKey: string;
28
+ sessionKey: string;
29
+ deferredAt: string;
30
+ reason: string;
31
+ };
26
32
  lastReview?: {
27
33
  reviewKey: string;
28
34
  sessionKey: string;
@@ -113,6 +113,23 @@ function finishReview(
113
113
  publishReviewStatus();
114
114
  }
115
115
 
116
+ function deferReview(task: KnowledgeReviewTask, reason: string): void {
117
+ clearTimeout(activeDeadlineTimer);
118
+ activeDeadlineTimer = undefined;
119
+ const attempts = reviewAttempts.get(task.reviewKey) ?? 1;
120
+ if (attempts <= 1) reviewAttempts.delete(task.reviewKey);
121
+ else reviewAttempts.set(task.reviewKey, attempts - 1);
122
+ if (!reviewStatus) return;
123
+ reviewStatus.activeReview = undefined;
124
+ reviewStatus.lastDeferred = {
125
+ reviewKey: task.reviewKey,
126
+ sessionKey: task.sessionKey,
127
+ deferredAt: new Date().toISOString(),
128
+ reason: boundedError(reason),
129
+ };
130
+ publishReviewStatus();
131
+ }
132
+
116
133
  function removeLeaderMetadata(token: string): void {
117
134
  try {
118
135
  const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8")) as { leaderToken?: string };
@@ -332,6 +349,13 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
332
349
  }
333
350
  }
334
351
 
352
+ function handleReviewDeferred(message: Extract<KnowledgeWorkerInput, { type: "review_deferred" }>): void {
353
+ const task = activeTask;
354
+ if (!task || message.requestId !== task.requestId || message.leaderToken !== leaderToken || !leaderServer) return;
355
+ deferReview(task, message.reason);
356
+ activeTask = undefined;
357
+ }
358
+
335
359
  const scanTimer = setInterval(() => { void scanForReview(); }, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
336
360
  scanTimer.unref();
337
361
 
@@ -348,6 +372,7 @@ parentPort.on("message", (message: KnowledgeWorkerInput) => {
348
372
  } else scheduleElection(0);
349
373
  return;
350
374
  }
375
+ if (message.type === "review_deferred") { handleReviewDeferred(message); return; }
351
376
  if (message.type === "review_result") { handleReviewResult(message); return; }
352
377
  if (message.type === "scan_now") { void scanForReview(); return; }
353
378
  stopped = true;
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
- import { resolve } from "node:path";
3
+ import { resolve, sep } from "node:path";
4
4
 
5
5
  import {
6
6
  buildContextEntries, parseSessionEntries, type SessionEntry, type SessionHeader,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
10
+ import { findLatestWorkflowRoot } from "../working-directory.ts";
10
11
  import { knowledgeDeltaDigest } from "./extractor.ts";
11
12
  import { sanitizeKnowledgeText } from "./sanitize.ts";
12
13
  import { projectKnowledgeKey } from "./store.ts";
@@ -21,6 +22,9 @@ interface ReviewPiece {
21
22
 
22
23
  const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
23
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;
24
28
 
25
29
  function hash(value: string, length = 64): string {
26
30
  return createHash("sha256").update(value).digest("hex").slice(0, length);
@@ -104,14 +108,114 @@ function buildValidationContext(entries: SessionEntry[]): string {
104
108
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
105
109
  }
106
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
+
107
201
  function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
202
+ const sessionRoot = resolve(header.cwd);
203
+ const workflowRootValue = findLatestWorkflowRoot(branch);
204
+ const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
205
+ let workingDirectory = sessionRoot;
108
206
  for (const entry of branch.slice().reverse()) {
109
207
  if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
110
208
  || !entry.data || typeof entry.data !== "object") continue;
111
209
  const cwd = (entry.data as Record<string, unknown>).cwd;
112
- if (typeof cwd === "string" && cwd) return resolve(cwd);
210
+ if (typeof cwd !== "string" || !cwd) continue;
211
+ workingDirectory = resolve(cwd);
212
+ break;
213
+ }
214
+ if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
215
+ return workflowRoot;
113
216
  }
114
- return resolve(header.cwd);
217
+ if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`)) return sessionRoot;
218
+ return workingDirectory;
115
219
  }
116
220
 
117
221
  export function discoverSessionFiles(root: string): string[] {
@@ -153,10 +257,12 @@ export function readReviewTask(
153
257
  const pending = branch.slice(cursorIndex + 1);
154
258
  if (pending.length === 0) return undefined;
155
259
  const delta = buildDelta(pending);
260
+ const loadedIds = loadedKnowledgeIds(pending);
261
+ const recallQuery = buildRecallQuery(pending, loadedIds);
156
262
  const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
157
263
  const firstEntryId = pending[0].id;
158
264
  const lastEntryId = pending[pending.length - 1].id;
159
- const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
265
+ const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
160
266
  const sessionFileHash = hash(resolve(sessionFile), 16);
161
267
  const sessionKey = hash(header.id, 24);
162
268
  const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
@@ -164,7 +270,8 @@ export function readReviewTask(
164
270
  return {
165
271
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
166
272
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
167
- 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,
168
275
  };
169
276
  }
170
277