@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.
- package/.pi/dist/lib/knowledge/extractor.js +17 -6
- package/.pi/dist/lib/knowledge/review-worker.js +30 -0
- package/.pi/dist/lib/knowledge/session-scanner.js +62 -13
- package/.pi/dist/lib/knowledge/store.js +128 -22
- package/.pi/dist/lib/runtime/defaults.js +8 -4
- 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 +58 -10
- package/.pi/lib/knowledge/extractor.ts +31 -6
- 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 +57 -12
- package/.pi/lib/knowledge/store.ts +126 -16
- package/.pi/lib/knowledge/types.ts +11 -0
- package/.pi/lib/knowledge/worker-protocol.ts +1 -0
- package/.pi/lib/runtime/defaults.ts +8 -4
- 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
|
|
|
@@ -8,13 +8,13 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
8
8
|
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
buildKnowledgeExtractionPrompt, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
|
|
11
|
+
buildKnowledgeExtractionPrompt, type ExistingKnowledgeContext, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
|
|
12
12
|
} from "../lib/knowledge/extractor.ts";
|
|
13
13
|
import { matchKnowledge } 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;
|
|
@@ -98,18 +98,58 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
98
98
|
try {
|
|
99
99
|
const ctx = runtime.context;
|
|
100
100
|
if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) throw new Error("no-configured-model");
|
|
101
|
-
if (!ctx.isIdle() || ctx.hasPendingMessages())
|
|
101
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
102
|
+
post({
|
|
103
|
+
type: "review_deferred",
|
|
104
|
+
leaderToken: message.leaderToken,
|
|
105
|
+
requestId: message.requestId,
|
|
106
|
+
reason: "model-executor-is-busy",
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
102
110
|
const controller = new AbortController();
|
|
103
111
|
runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
|
|
104
112
|
timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
|
|
105
113
|
timeout.unref();
|
|
114
|
+
const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
|
|
115
|
+
const applicableCatalog = {
|
|
116
|
+
...snapshot.catalog,
|
|
117
|
+
items: snapshot.catalog.items.filter((entry) => (
|
|
118
|
+
entry.scope === "global" || entry.scope === `project:${message.task.projectKey}`
|
|
119
|
+
)),
|
|
120
|
+
};
|
|
121
|
+
const related: ExistingKnowledgeContext[] = matchKnowledge(
|
|
122
|
+
message.task.delta,
|
|
123
|
+
applicableCatalog,
|
|
124
|
+
KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
|
|
125
|
+
).map((entry, index) => {
|
|
126
|
+
const reference: ExistingKnowledgeContext = {
|
|
127
|
+
id: entry.id,
|
|
128
|
+
title: entry.title,
|
|
129
|
+
summary: entry.summary,
|
|
130
|
+
keywords: entry.keywords,
|
|
131
|
+
track: entry.track,
|
|
132
|
+
};
|
|
133
|
+
if (index >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyItems) return reference;
|
|
134
|
+
const content = loadKnowledgeById(entry.id, message.task.projectKey)?.content
|
|
135
|
+
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyChars);
|
|
136
|
+
return content ? { ...reference, content } : reference;
|
|
137
|
+
});
|
|
106
138
|
const response = await ctx.modelRegistry.complete(
|
|
107
139
|
ctx.model,
|
|
108
140
|
{
|
|
109
141
|
systemPrompt: KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
|
|
110
142
|
messages: [{
|
|
111
143
|
role: "user",
|
|
112
|
-
content: [{
|
|
144
|
+
content: [{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: buildKnowledgeExtractionPrompt(
|
|
147
|
+
message.task.projectRoot,
|
|
148
|
+
message.task.delta,
|
|
149
|
+
message.task.context,
|
|
150
|
+
related,
|
|
151
|
+
),
|
|
152
|
+
}],
|
|
113
153
|
timestamp: Date.now(),
|
|
114
154
|
}],
|
|
115
155
|
},
|
|
@@ -120,10 +160,12 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
120
160
|
.map((item) => item.text).join("\n");
|
|
121
161
|
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
|
|
122
162
|
} catch (error) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
163
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
164
|
+
if (reason === "model-executor-is-busy") {
|
|
165
|
+
post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
|
|
166
|
+
} else {
|
|
167
|
+
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
|
|
168
|
+
}
|
|
127
169
|
try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
|
|
128
170
|
} finally {
|
|
129
171
|
clearTimeout(timeout);
|
|
@@ -184,7 +226,13 @@ function configureForContext(ctx: ExtensionContext): void {
|
|
|
184
226
|
}
|
|
185
227
|
|
|
186
228
|
function currentProject(ctx: ExtensionContext): { root: string; key: string } {
|
|
187
|
-
const
|
|
229
|
+
const workingDirectory = resolve(getWorkingDirectory(ctx.sessionManager));
|
|
230
|
+
const workflowRootValue = findLatestWorkflowRoot(ctx.sessionManager.getEntries());
|
|
231
|
+
const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
|
|
232
|
+
const root = workflowRoot
|
|
233
|
+
&& (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))
|
|
234
|
+
? workflowRoot
|
|
235
|
+
: workingDirectory;
|
|
188
236
|
return { root, key: projectKnowledgeKey(root) };
|
|
189
237
|
}
|
|
190
238
|
|
|
@@ -2,6 +2,15 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
|
|
4
4
|
|
|
5
|
+
export interface ExistingKnowledgeContext {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
keywords: string[];
|
|
10
|
+
track: "rule" | "topic";
|
|
11
|
+
content?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
|
|
6
15
|
Review only the supplied conversation delta. Return strict JSON and no markdown.
|
|
7
16
|
Persist only knowledge that is likely to be useful in future sessions:
|
|
@@ -10,15 +19,31 @@ Persist only knowledge that is likely to be useful in future sessions:
|
|
|
10
19
|
- reusable architecture, testing, debugging, deployment, or operational knowledge.
|
|
11
20
|
Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
|
|
12
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.
|
|
13
|
-
|
|
14
|
-
Use
|
|
22
|
+
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
|
+
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
|
+
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.
|
|
15
25
|
Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
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
|
+
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.
|
|
28
|
+
Return: {"candidates":[{"key":"stable semantic key","targetId":"existing-id or null","title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false,"durability":"stable"}]}
|
|
18
29
|
Return {"candidates":[]} when nothing meets the threshold.`;
|
|
19
30
|
|
|
20
|
-
export function buildKnowledgeExtractionPrompt(
|
|
21
|
-
|
|
31
|
+
export function buildKnowledgeExtractionPrompt(
|
|
32
|
+
projectRoot: string,
|
|
33
|
+
delta: string,
|
|
34
|
+
context = "",
|
|
35
|
+
existing: ExistingKnowledgeContext[] = [],
|
|
36
|
+
): string {
|
|
37
|
+
const related = existing.length > 0 ? JSON.stringify(existing) : "[]";
|
|
38
|
+
return [
|
|
39
|
+
`Project root: ${projectRoot}`,
|
|
40
|
+
"",
|
|
41
|
+
"<relevant_existing_knowledge>", related, "</relevant_existing_knowledge>",
|
|
42
|
+
"",
|
|
43
|
+
"<prior_validation_context>", context, "</prior_validation_context>",
|
|
44
|
+
"",
|
|
45
|
+
"<conversation_delta>", delta, "</conversation_delta>",
|
|
46
|
+
].join("\n");
|
|
22
47
|
}
|
|
23
48
|
|
|
24
49
|
export function parseCandidateEnvelope(text: string): unknown[] {
|
|
@@ -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";
|
|
@@ -19,6 +20,9 @@ interface ReviewPiece {
|
|
|
19
20
|
text: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
|
|
24
|
+
const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
|
|
25
|
+
|
|
22
26
|
function hash(value: string, length = 64): string {
|
|
23
27
|
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
24
28
|
}
|
|
@@ -35,20 +39,21 @@ function textContent(content: unknown): string {
|
|
|
35
39
|
|
|
36
40
|
function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefined {
|
|
37
41
|
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
38
|
-
return { index, priority:
|
|
42
|
+
return { index, priority: 2, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
|
|
39
43
|
}
|
|
40
44
|
if (entry.type === "custom" && entry.customType.startsWith("hwcode-workflow")) {
|
|
41
45
|
return {
|
|
42
|
-
index, priority:
|
|
46
|
+
index, priority: 1, category: "workflow",
|
|
43
47
|
text: `[workflow_state:${entry.customType}]\n${sanitizeKnowledgeText(JSON.stringify(entry.data ?? {})).slice(0, 6_000)}`,
|
|
44
48
|
};
|
|
45
49
|
}
|
|
46
50
|
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
|
|
47
51
|
const message = entry.message as unknown as Record<string, unknown>;
|
|
48
52
|
const role = typeof message.role === "string" ? message.role : "message";
|
|
49
|
-
const content = sanitizeKnowledgeText(textContent(message.content));
|
|
53
|
+
const content = sanitizeKnowledgeText(textContent(message.content).replace(INJECTED_SKILL_RE, "[loaded skill omitted]"));
|
|
50
54
|
if (!content) return undefined;
|
|
51
|
-
const priority = role === "user" ? 0 : role === "toolResult"
|
|
55
|
+
const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
|
|
56
|
+
: role === "toolResult" ? 4 : 3;
|
|
52
57
|
const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
|
|
53
58
|
const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
|
|
54
59
|
return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
|
|
@@ -61,24 +66,63 @@ function buildDelta(entries: SessionEntry[]): string {
|
|
|
61
66
|
user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
|
|
62
67
|
};
|
|
63
68
|
let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars;
|
|
64
|
-
for (const piece of pieces.slice().sort((left, right) =>
|
|
69
|
+
for (const piece of pieces.slice().sort((left, right) => {
|
|
70
|
+
if (left.priority !== right.priority) return left.priority - right.priority;
|
|
71
|
+
if (left.category === right.category && left.category !== "user") return right.index - left.index;
|
|
72
|
+
return left.index - right.index;
|
|
73
|
+
})) {
|
|
65
74
|
if (remaining <= 0) break;
|
|
66
|
-
const
|
|
75
|
+
const separatorCharacters = selected.length > 0 ? 2 : 0;
|
|
76
|
+
if (remaining <= separatorCharacters) break;
|
|
77
|
+
const text = piece.text.slice(0, Math.min(remaining - separatorCharacters, categoryRemaining[piece.category]));
|
|
67
78
|
if (text) selected.push({ ...piece, text });
|
|
68
|
-
remaining -= text.length;
|
|
79
|
+
remaining -= text.length + separatorCharacters;
|
|
69
80
|
categoryRemaining[piece.category] -= text.length;
|
|
70
81
|
}
|
|
71
|
-
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
|
|
82
|
+
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
|
|
83
|
+
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildValidationContext(entries: SessionEntry[]): string {
|
|
87
|
+
const pieces = entries.map(reviewPiece).filter((piece): piece is ReviewPiece => Boolean(piece));
|
|
88
|
+
const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
|
|
89
|
+
const evidence = pieces.filter((piece) => (
|
|
90
|
+
(piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
|
|
91
|
+
&& FAILURE_EVIDENCE_RE.test(piece.text)
|
|
92
|
+
));
|
|
93
|
+
if (latestWorkflow && !evidence.includes(latestWorkflow)) evidence.push(latestWorkflow);
|
|
94
|
+
let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars;
|
|
95
|
+
const selected: ReviewPiece[] = [];
|
|
96
|
+
for (const piece of evidence.slice().sort((left, right) => right.index - left.index)) {
|
|
97
|
+
if (remaining <= 0) break;
|
|
98
|
+
const separatorCharacters = selected.length > 0 ? 2 : 0;
|
|
99
|
+
if (remaining <= separatorCharacters) break;
|
|
100
|
+
const text = piece.text.slice(0, remaining - separatorCharacters);
|
|
101
|
+
if (text) selected.push({ ...piece, text });
|
|
102
|
+
remaining -= text.length + separatorCharacters;
|
|
103
|
+
}
|
|
104
|
+
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
|
|
105
|
+
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
|
|
72
106
|
}
|
|
73
107
|
|
|
74
108
|
function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
|
|
109
|
+
const sessionRoot = resolve(header.cwd);
|
|
110
|
+
const workflowRootValue = findLatestWorkflowRoot(branch);
|
|
111
|
+
const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
|
|
112
|
+
let workingDirectory = sessionRoot;
|
|
75
113
|
for (const entry of branch.slice().reverse()) {
|
|
76
114
|
if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
|
|
77
115
|
|| !entry.data || typeof entry.data !== "object") continue;
|
|
78
116
|
const cwd = (entry.data as Record<string, unknown>).cwd;
|
|
79
|
-
if (typeof cwd
|
|
117
|
+
if (typeof cwd !== "string" || !cwd) continue;
|
|
118
|
+
workingDirectory = resolve(cwd);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
|
|
122
|
+
return workflowRoot;
|
|
80
123
|
}
|
|
81
|
-
|
|
124
|
+
if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`)) return sessionRoot;
|
|
125
|
+
return workingDirectory;
|
|
82
126
|
}
|
|
83
127
|
|
|
84
128
|
export function discoverSessionFiles(root: string): string[] {
|
|
@@ -120,6 +164,7 @@ export function readReviewTask(
|
|
|
120
164
|
const pending = branch.slice(cursorIndex + 1);
|
|
121
165
|
if (pending.length === 0) return undefined;
|
|
122
166
|
const delta = buildDelta(pending);
|
|
167
|
+
const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
|
|
123
168
|
const firstEntryId = pending[0].id;
|
|
124
169
|
const lastEntryId = pending[pending.length - 1].id;
|
|
125
170
|
const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
|
|
@@ -130,7 +175,7 @@ export function readReviewTask(
|
|
|
130
175
|
return {
|
|
131
176
|
requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
|
|
132
177
|
projectRoot, projectKey: projectKnowledgeKey(projectRoot),
|
|
133
|
-
firstEntryId, lastEntryId, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
178
|
+
firstEntryId, lastEntryId, context, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
134
179
|
};
|
|
135
180
|
}
|
|
136
181
|
|