@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.
- package/.pi/dist/lib/knowledge/extractor.js +2 -0
- package/.pi/dist/lib/knowledge/review-worker.js +30 -0
- package/.pi/dist/lib/knowledge/session-scanner.js +128 -6
- package/.pi/dist/lib/knowledge/store.js +92 -29
- package/.pi/dist/lib/runtime/session-state.js +9 -0
- package/.pi/dist/lib/workflows/state.js +159 -0
- package/.pi/dist/lib/working-directory.js +170 -0
- package/.pi/extensions/knowledge.ts +63 -22
- package/.pi/lib/knowledge/extractor.ts +2 -0
- package/.pi/lib/knowledge/matcher.ts +36 -1
- package/.pi/lib/knowledge/review-status.ts +6 -0
- package/.pi/lib/knowledge/review-worker.ts +25 -0
- package/.pi/lib/knowledge/session-scanner.ts +112 -5
- package/.pi/lib/knowledge/store.ts +99 -22
- package/.pi/lib/knowledge/types.ts +13 -0
- package/.pi/lib/knowledge/worker-protocol.ts +1 -0
- package/.pi/lib/working-directory.ts +10 -0
- package/package.json +1 -1
|
@@ -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
|
|
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())
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
108
|
-
|
|
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-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
273
|
+
firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
|
|
274
|
+
deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
168
275
|
};
|
|
169
276
|
}
|
|
170
277
|
|