@hadooppei/hwcode 1.0.13 → 1.0.16
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/session-scanner.js +118 -22
- package/.pi/dist/lib/knowledge/store.js +33 -5
- package/.pi/dist/lib/working-directory.js +4 -0
- package/.pi/extensions/knowledge.ts +37 -24
- package/.pi/extensions/workflows/cloud/activation.ts +2 -2
- package/.pi/extensions/workflows/sdd.ts +2 -2
- package/.pi/extensions/workflows/vibe.ts +2 -2
- package/.pi/extensions/workflows/workspace-guard.ts +3 -3
- package/.pi/lib/knowledge/extractor.ts +2 -0
- package/.pi/lib/knowledge/matcher.ts +36 -1
- package/.pi/lib/knowledge/session-scanner.ts +101 -21
- package/.pi/lib/knowledge/store.ts +38 -5
- package/.pi/lib/knowledge/types.ts +4 -0
- package/.pi/lib/working-directory.ts +5 -0
- package/package.json +1 -1
|
@@ -8,12 +8,14 @@ Persist only knowledge that is likely to be useful in future sessions:
|
|
|
8
8
|
- reusable architecture, testing, debugging, deployment, or operational knowledge.
|
|
9
9
|
Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
|
|
10
10
|
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.
|
|
11
|
+
Do not persist hypotheses, suspected causes, or statements marked as possible, pending validation, or unconfirmed. Omit them until direct evidence verifies them.
|
|
11
12
|
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.
|
|
12
13
|
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.
|
|
13
14
|
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.
|
|
14
15
|
Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
|
|
15
16
|
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.
|
|
16
17
|
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.
|
|
18
|
+
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.
|
|
17
19
|
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
20
|
Return {"candidates":[]} when nothing meets the threshold.`;
|
|
19
21
|
export function buildKnowledgeExtractionPrompt(projectRoot, delta, context = "", existing = []) {
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { resolve
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
4
|
import { buildContextEntries, parseSessionEntries, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
|
|
6
|
-
import {
|
|
6
|
+
import { canonicalizeDirectory } from "../working-directory.js";
|
|
7
7
|
import { knowledgeDeltaDigest } from "./extractor.js";
|
|
8
8
|
import { sanitizeKnowledgeText } from "./sanitize.js";
|
|
9
9
|
import { projectKnowledgeKey } from "./store.js";
|
|
10
10
|
const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
|
|
11
11
|
const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
|
|
12
|
+
const SAFE_KNOWLEDGE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
|
|
13
|
+
const MAX_RECALL_QUERY_CHARS = 4_000;
|
|
14
|
+
const MAX_LOADED_KNOWLEDGE_IDS = 8;
|
|
12
15
|
function hash(value, length = 64) {
|
|
13
16
|
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
14
17
|
}
|
|
@@ -98,27 +101,117 @@ function buildValidationContext(entries) {
|
|
|
98
101
|
return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
|
|
99
102
|
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
|
|
100
103
|
}
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
function messageRecord(entry) {
|
|
105
|
+
return entry.type === "message" && entry.message && typeof entry.message === "object"
|
|
106
|
+
? entry.message
|
|
107
|
+
: undefined;
|
|
108
|
+
}
|
|
109
|
+
function loadedKnowledgeIds(entries) {
|
|
110
|
+
const ids = [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const content = messageRecord(entry)?.content;
|
|
113
|
+
if (!Array.isArray(content))
|
|
109
114
|
continue;
|
|
110
|
-
const
|
|
111
|
-
|
|
115
|
+
for (const item of content) {
|
|
116
|
+
if (!item || typeof item !== "object")
|
|
117
|
+
continue;
|
|
118
|
+
const call = item;
|
|
119
|
+
if (call.type !== "toolCall" || call.name !== "hwcode_knowledge_lookup"
|
|
120
|
+
|| !call.arguments || typeof call.arguments !== "object")
|
|
121
|
+
continue;
|
|
122
|
+
const id = call.arguments.id;
|
|
123
|
+
if (typeof id !== "string" || !SAFE_KNOWLEDGE_ID_RE.test(id))
|
|
124
|
+
continue;
|
|
125
|
+
const existing = ids.indexOf(id);
|
|
126
|
+
if (existing >= 0)
|
|
127
|
+
ids.splice(existing, 1);
|
|
128
|
+
ids.push(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return ids.slice(-MAX_LOADED_KNOWLEDGE_IDS);
|
|
132
|
+
}
|
|
133
|
+
function compactRecallValue(value, maxChars = 600) {
|
|
134
|
+
return typeof value === "string" ? sanitizeKnowledgeText(value).replace(/\s+/gu, " ").trim().slice(0, maxChars) : "";
|
|
135
|
+
}
|
|
136
|
+
function workflowRecallLines(entry) {
|
|
137
|
+
if (entry.type !== "custom" || !entry.customType.startsWith("hwcode-workflow")
|
|
138
|
+
|| !entry.data || typeof entry.data !== "object")
|
|
139
|
+
return [];
|
|
140
|
+
const data = entry.data;
|
|
141
|
+
const details = data.details && typeof data.details === "object" ? data.details : undefined;
|
|
142
|
+
const lines = [];
|
|
143
|
+
const request = compactRecallValue(details?.request ?? data.request, 800);
|
|
144
|
+
if (request)
|
|
145
|
+
lines.push(`Task objective: ${request}`);
|
|
146
|
+
const addSteps = (label, value, maxItems) => {
|
|
147
|
+
if (!Array.isArray(value))
|
|
148
|
+
return;
|
|
149
|
+
for (const raw of value.slice(-maxItems)) {
|
|
150
|
+
if (typeof raw === "string") {
|
|
151
|
+
const text = compactRecallValue(raw);
|
|
152
|
+
if (text)
|
|
153
|
+
lines.push(`${label}: ${text}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!raw || typeof raw !== "object")
|
|
157
|
+
continue;
|
|
158
|
+
const step = raw;
|
|
159
|
+
const parts = [step.approach, step.intent, step.reason].map((item) => compactRecallValue(item, 320)).filter(Boolean);
|
|
160
|
+
if (typeof step.command === "string")
|
|
161
|
+
parts.push(step.command);
|
|
162
|
+
if (Array.isArray(step.args)) {
|
|
163
|
+
const operation = step.args.slice(0, 2).filter((item) => typeof item === "string")
|
|
164
|
+
.map((item) => compactRecallValue(item, 100)).filter(Boolean).join(" ");
|
|
165
|
+
if (operation)
|
|
166
|
+
parts.push(operation);
|
|
167
|
+
}
|
|
168
|
+
const text = [...new Set(parts)].join("; ");
|
|
169
|
+
if (text)
|
|
170
|
+
lines.push(`${label}: ${text}`);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
addSteps("Verified success", details?.successfulSteps ?? data.successfulSteps, 12);
|
|
174
|
+
addSteps("Verified failure", details?.failedApproaches ?? data.failedApproaches, 8);
|
|
175
|
+
return lines;
|
|
176
|
+
}
|
|
177
|
+
function buildRecallQuery(entries, ids) {
|
|
178
|
+
const lines = [];
|
|
179
|
+
const workflows = entries.map(workflowRecallLines).filter((item) => item.length > 0);
|
|
180
|
+
const workflowLines = workflows.at(-1) ?? [];
|
|
181
|
+
lines.push(...workflowLines.filter((line) => line.startsWith("Task objective:")));
|
|
182
|
+
for (const entry of entries.slice().reverse()) {
|
|
183
|
+
const message = messageRecord(entry);
|
|
184
|
+
if (!message || message.role !== "user")
|
|
112
185
|
continue;
|
|
113
|
-
|
|
114
|
-
|
|
186
|
+
const text = compactRecallValue(textContent(message.content).replace(INJECTED_SKILL_RE, ""), 800);
|
|
187
|
+
if (text)
|
|
188
|
+
lines.push(`User request: ${text}`);
|
|
189
|
+
if (lines.filter((line) => line.startsWith("User request:")).length >= 2)
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
for (const entry of entries.slice().reverse()) {
|
|
193
|
+
const message = messageRecord(entry);
|
|
194
|
+
if (!message || message.role !== "assistant")
|
|
195
|
+
continue;
|
|
196
|
+
const text = compactRecallValue(textContent(message.content), 1_000);
|
|
197
|
+
if (text) {
|
|
198
|
+
lines.push(`Latest conclusion: ${text}`);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
115
201
|
}
|
|
116
|
-
|
|
117
|
-
|
|
202
|
+
for (const id of ids)
|
|
203
|
+
lines.push(`Loaded knowledge: ${id}`);
|
|
204
|
+
lines.push(...workflowLines.filter((line) => line.startsWith("Verified failure:")));
|
|
205
|
+
lines.push(...workflowLines.filter((line) => line.startsWith("Verified success:")));
|
|
206
|
+
return lines.join("\n").slice(0, MAX_RECALL_QUERY_CHARS);
|
|
207
|
+
}
|
|
208
|
+
function projectRootForSession(header) {
|
|
209
|
+
try {
|
|
210
|
+
return canonicalizeDirectory(header.cwd);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return resolve(header.cwd);
|
|
118
214
|
}
|
|
119
|
-
if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`))
|
|
120
|
-
return sessionRoot;
|
|
121
|
-
return workingDirectory;
|
|
122
215
|
}
|
|
123
216
|
export function discoverSessionFiles(root) {
|
|
124
217
|
const files = [];
|
|
@@ -176,18 +269,21 @@ export function readReviewTask(sessionFile, cursor, now = Date.now()) {
|
|
|
176
269
|
if (pending.length === 0)
|
|
177
270
|
return undefined;
|
|
178
271
|
const delta = buildDelta(pending);
|
|
272
|
+
const loadedIds = loadedKnowledgeIds(pending);
|
|
273
|
+
const recallQuery = buildRecallQuery(pending, loadedIds);
|
|
179
274
|
const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
|
|
180
275
|
const firstEntryId = pending[0].id;
|
|
181
276
|
const lastEntryId = pending[pending.length - 1].id;
|
|
182
|
-
const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
|
|
277
|
+
const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
|
|
183
278
|
const sessionFileHash = hash(resolve(sessionFile), 16);
|
|
184
279
|
const sessionKey = hash(header.id, 24);
|
|
185
280
|
const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
|
|
186
|
-
const projectRoot = projectRootForSession(header
|
|
281
|
+
const projectRoot = projectRootForSession(header);
|
|
187
282
|
return {
|
|
188
283
|
requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
|
|
189
284
|
projectRoot, projectKey: projectKnowledgeKey(projectRoot),
|
|
190
|
-
firstEntryId, lastEntryId, context, delta,
|
|
285
|
+
firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
|
|
286
|
+
deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
191
287
|
};
|
|
192
288
|
}
|
|
193
289
|
export function findNextReviewTask(sessionsRoot, reviews, now = Date.now()) {
|
|
@@ -9,6 +9,12 @@ const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
|
|
|
9
9
|
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
|
|
10
10
|
const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
|
|
11
11
|
const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
|
|
12
|
+
const UNVERIFIED_CLAIM_RE = /(?:待验证|尚未(?:验证|确认)|未经(?:验证|确认)|未(?:经)?证实|推测|猜测|疑似|可能是|可能由于|可能导致|或许|unverified|unconfirmed|speculat(?:e|ive|ion)|hypothes(?:is|ize)|suspect(?:ed|ion)?|possibly|probably)/iu;
|
|
13
|
+
const VOLATILE_ARGUMENT_RE = /--(?:namespace|name|server_name|security_group_id|subnet_id|vpc_id|image_id|project_id)(?:\.\d+)?=(?!<[^>]+>|\$?\{)[^\s`"']+/iu;
|
|
14
|
+
const DISTINCTIVE_COMMON_TERMS = new Set([
|
|
15
|
+
"cloud", "huawei", "huaweicloud", "hcloud", "topic", "rule", "sop", "project", "region", "create",
|
|
16
|
+
"deploy", "deployment", "service", "workflow", "cn", "south",
|
|
17
|
+
]);
|
|
12
18
|
export class KnowledgeCommitBusyError extends Error {
|
|
13
19
|
}
|
|
14
20
|
function emptyCatalog() {
|
|
@@ -120,9 +126,14 @@ function semanticDuplicateScore(candidate, entry) {
|
|
|
120
126
|
return 0;
|
|
121
127
|
const titleScore = containment(comparisonTerms(candidate.title), comparisonTerms(entry.title));
|
|
122
128
|
const keywordScore = containment(comparisonTerms(candidate.keywords.join(" ")), comparisonTerms(entry.keywords.join(" ")));
|
|
123
|
-
if (titleScore
|
|
129
|
+
if (titleScore >= 0.8 && keywordScore >= 0.5)
|
|
130
|
+
return titleScore * 0.7 + keywordScore * 0.3;
|
|
131
|
+
const candidateTerms = comparisonTerms(`${candidate.title} ${candidate.keywords.join(" ")}`);
|
|
132
|
+
const entryTerms = comparisonTerms(`${entry.title} ${entry.keywords.join(" ")}`);
|
|
133
|
+
const distinctiveShared = [...candidateTerms].filter((term) => (entryTerms.has(term) && term.length >= 3 && !DISTINCTIVE_COMMON_TERMS.has(term))).length;
|
|
134
|
+
if (keywordScore < 0.5 || distinctiveShared < 3)
|
|
124
135
|
return 0;
|
|
125
|
-
return
|
|
136
|
+
return keywordScore * 0.7 + Math.min(distinctiveShared / 6, 1) * 0.3;
|
|
126
137
|
}
|
|
127
138
|
function semanticDuplicateIndex(candidate, catalog) {
|
|
128
139
|
let bestIndex = -1;
|
|
@@ -167,7 +178,19 @@ function asStringArray(value, maxItems) {
|
|
|
167
178
|
return [...new Set(value.filter((item) => typeof item === "string")
|
|
168
179
|
.map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
|
|
169
180
|
}
|
|
170
|
-
function
|
|
181
|
+
function withoutLeadingTitle(body) {
|
|
182
|
+
return body.replace(/^#\s+[^\n]*(?:\n+|$)/u, "").trim();
|
|
183
|
+
}
|
|
184
|
+
function containsVolatileDetail(body, projectRoot) {
|
|
185
|
+
const projectName = basename(resolve(projectRoot));
|
|
186
|
+
const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
187
|
+
const namedProject = projectName.length >= 5
|
|
188
|
+
&& new RegExp(`(?:项目|project)\\s*(?:[::=]|is)?\\s*[\`'\"]?${escapedProjectName}(?![\\p{L}\\p{N}-])`, "iu").test(body);
|
|
189
|
+
return VOLATILE_ARGUMENT_RE.test(body)
|
|
190
|
+
|| namedProject
|
|
191
|
+
|| /\.hwcode\/cloud\/runs\//iu.test(body);
|
|
192
|
+
}
|
|
193
|
+
function normalizeCandidate(value, projectKey, projectRoot, validationText) {
|
|
171
194
|
if (!value || typeof value !== "object")
|
|
172
195
|
return { reason: "invalid-schema" };
|
|
173
196
|
const raw = value;
|
|
@@ -183,10 +206,15 @@ function normalizeCandidate(value, projectKey, validationText) {
|
|
|
183
206
|
return { reason: "unknown-target" };
|
|
184
207
|
const explicitUserDirective = raw.explicitUserDirective === true;
|
|
185
208
|
const scope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
|
|
186
|
-
const body = compactKnowledgeText(raw.body, 20_000);
|
|
209
|
+
const body = withoutLeadingTitle(compactKnowledgeText(raw.body, 20_000));
|
|
187
210
|
const evidence = asStringArray(raw.evidence, 8);
|
|
188
211
|
if (!body || evidence.length === 0)
|
|
189
212
|
return { reason: "missing-body-or-evidence" };
|
|
213
|
+
const candidateClaims = `${raw.title}\n${raw.summary}\n${body}`;
|
|
214
|
+
if (!explicitUserDirective && UNVERIFIED_CLAIM_RE.test(candidateClaims))
|
|
215
|
+
return { reason: "unverified-claim" };
|
|
216
|
+
if (containsVolatileDetail(body, projectRoot))
|
|
217
|
+
return { reason: "volatile-detail" };
|
|
190
218
|
if (contradictsVerifiedFailures(body, validationText))
|
|
191
219
|
return { reason: "contradicts-verified-failure" };
|
|
192
220
|
const requestedTrack = raw.storageHint === "rule" ? "rule" : "topic";
|
|
@@ -352,7 +380,7 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
|
|
|
352
380
|
const result = { saved: 0, updated: 0, pending: 0, skipped: 0 };
|
|
353
381
|
const rejected = [];
|
|
354
382
|
for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
|
|
355
|
-
const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
|
|
383
|
+
const normalized = normalizeCandidate(value, task.projectKey, task.projectRoot, `${task.context ?? ""}\n${task.delta}`);
|
|
356
384
|
if (!("candidate" in normalized)) {
|
|
357
385
|
recordSkip(result, normalized.reason);
|
|
358
386
|
rejected.push({ reason: normalized.reason, value });
|
|
@@ -39,6 +39,10 @@ export function getWorkingDirectoryState(source) {
|
|
|
39
39
|
export function getWorkingDirectory(source) {
|
|
40
40
|
return getWorkingDirectoryState(source).cwd;
|
|
41
41
|
}
|
|
42
|
+
/** The immutable project root captured when the session was created. */
|
|
43
|
+
export function getSessionProjectRoot(source) {
|
|
44
|
+
return canonicalizeDirectory(source.getCwd());
|
|
45
|
+
}
|
|
42
46
|
export function setWorkingDirectoryState(source, state) {
|
|
43
47
|
workingDirectories.set(source.getSessionId(), state);
|
|
44
48
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { dirname
|
|
3
|
+
import { dirname } 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 {
|
|
17
|
+
import { getSessionProjectRoot } 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,6 +105,7 @@ 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");
|
|
@@ -108,8 +119,13 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
108
119
|
return;
|
|
109
120
|
}
|
|
110
121
|
const controller = new AbortController();
|
|
111
|
-
|
|
112
|
-
|
|
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);
|
|
113
129
|
timeout.unref();
|
|
114
130
|
const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
|
|
115
131
|
const applicableCatalog = {
|
|
@@ -118,9 +134,10 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
118
134
|
entry.scope === "global" || entry.scope === `project:${message.task.projectKey}`
|
|
119
135
|
)),
|
|
120
136
|
};
|
|
121
|
-
const related: ExistingKnowledgeContext[] =
|
|
122
|
-
message.task.delta,
|
|
137
|
+
const related: ExistingKnowledgeContext[] = recallKnowledge(
|
|
138
|
+
message.task.recallQuery || message.task.delta,
|
|
123
139
|
applicableCatalog,
|
|
140
|
+
message.task.loadedKnowledgeIds ?? [],
|
|
124
141
|
KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
|
|
125
142
|
).map((entry, index) => {
|
|
126
143
|
const reference: ExistingKnowledgeContext = {
|
|
@@ -155,13 +172,15 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
|
|
|
155
172
|
},
|
|
156
173
|
{ signal: controller.signal, reasoningEffort: "low", cacheRetention: "none", sessionId: randomUUID() },
|
|
157
174
|
);
|
|
158
|
-
if (controller.signal.aborted) throw new Error("knowledge-review-
|
|
175
|
+
if (controller.signal.aborted) throw new Error(review.abortReason ?? "knowledge-review-interrupted");
|
|
159
176
|
const raw = response.content.filter((item): item is { type: "text"; text: string } => item.type === "text")
|
|
160
177
|
.map((item) => item.text).join("\n");
|
|
161
178
|
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
|
|
162
179
|
} catch (error) {
|
|
163
|
-
const reason =
|
|
164
|
-
|
|
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)) {
|
|
165
184
|
post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
|
|
166
185
|
} else {
|
|
167
186
|
post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
|
|
@@ -181,7 +200,7 @@ function handleWorkerMessage(message: KnowledgeWorkerOutput): void {
|
|
|
181
200
|
}
|
|
182
201
|
if (message.type === "review_request") { void runReview(message); return; }
|
|
183
202
|
if (message.type === "review_cancel" && runtime.activeReview?.requestId === message.requestId) {
|
|
184
|
-
|
|
203
|
+
cancelActiveReview(message.reason);
|
|
185
204
|
return;
|
|
186
205
|
}
|
|
187
206
|
if (message.type === "review_failed") reportWorkerIssue(message.error);
|
|
@@ -196,7 +215,7 @@ function ensureWorker(): Worker | undefined {
|
|
|
196
215
|
worker.on("message", (message: KnowledgeWorkerOutput) => handleWorkerMessage(message));
|
|
197
216
|
worker.on("error", (error) => {
|
|
198
217
|
reportWorkerIssue(error);
|
|
199
|
-
cancelActiveReview();
|
|
218
|
+
cancelActiveReview("knowledge-worker-restarting");
|
|
200
219
|
if (runtime.worker === worker) {
|
|
201
220
|
runtime.worker = undefined;
|
|
202
221
|
runtime.modelAvailable = undefined;
|
|
@@ -226,13 +245,7 @@ function configureForContext(ctx: ExtensionContext): void {
|
|
|
226
245
|
}
|
|
227
246
|
|
|
228
247
|
function currentProject(ctx: ExtensionContext): { root: string; key: string } {
|
|
229
|
-
const
|
|
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;
|
|
248
|
+
const root = getSessionProjectRoot(ctx.sessionManager);
|
|
236
249
|
return { root, key: projectKnowledgeKey(root) };
|
|
237
250
|
}
|
|
238
251
|
|
|
@@ -292,14 +305,14 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
|
|
|
292
305
|
|
|
293
306
|
pi.on("input", (_event, ctx) => {
|
|
294
307
|
runtime.context = ctx;
|
|
295
|
-
cancelActiveReview();
|
|
308
|
+
cancelActiveReview("foreground-model-became-busy");
|
|
296
309
|
updateCapability();
|
|
297
310
|
return undefined;
|
|
298
311
|
});
|
|
299
312
|
|
|
300
313
|
pi.on("agent_start", (_event, ctx) => {
|
|
301
314
|
runtime.context = ctx;
|
|
302
|
-
cancelActiveReview();
|
|
315
|
+
cancelActiveReview("foreground-model-became-busy");
|
|
303
316
|
});
|
|
304
317
|
|
|
305
318
|
pi.on("agent_settled", (_event, ctx) => {
|
|
@@ -309,7 +322,7 @@ export default function registerKnowledgeExtension(pi: ExtensionAPI): void {
|
|
|
309
322
|
});
|
|
310
323
|
|
|
311
324
|
pi.on("session_shutdown", (event) => {
|
|
312
|
-
cancelActiveReview();
|
|
325
|
+
cancelActiveReview("session-shutdown");
|
|
313
326
|
if (event.reason === "quit") {
|
|
314
327
|
runtime.context = undefined;
|
|
315
328
|
updateCapability();
|
|
@@ -9,7 +9,7 @@ import { modelConfigurationIssue } from "../../../lib/models/readiness.ts";
|
|
|
9
9
|
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
10
10
|
import { remoteRunnerPaths, userRuntimePaths } from "../../../lib/runtime/paths.ts";
|
|
11
11
|
import { canonicalizeWorkspaceRoot } from "../../../lib/workflow-guard.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { getSessionProjectRoot } from "../../../lib/working-directory.ts";
|
|
13
13
|
import { checkProviderCli, formatValidationFailure, validateCloudCredentials } from "../../../lib/workflows/cloud/adapters.ts";
|
|
14
14
|
import { cloudTerraformTemplateSource, listCloudTerraformTemplates, materializeCloudTerraformTemplate, type CloudTerraformTemplate } from "../../../lib/workflows/cloud/bundles.ts";
|
|
15
15
|
import { CLOUD_PROVIDERS, getCloudProvider, inaccessibleCloudCliMessage, missingCloudCliMessage, type CloudCredentials, type CloudVendorId } from "../../../lib/workflows/cloud/providers.ts";
|
|
@@ -126,7 +126,7 @@ export async function activateCloudWorkflow(runtime: CloudExtensionRuntime, args
|
|
|
126
126
|
if (!ctx.isIdle()) { notify(ctx, "请等待当前响应完成后再启动 HWCode Cloud。", "warning"); return; }
|
|
127
127
|
if (!(await ensureConversationModel(ctx))) return;
|
|
128
128
|
|
|
129
|
-
const root = canonicalizeWorkspaceRoot(
|
|
129
|
+
const root = canonicalizeWorkspaceRoot(getSessionProjectRoot(ctx.sessionManager));
|
|
130
130
|
const currentWorkflow = activeWorkflow(ctx.sessionManager.getEntries());
|
|
131
131
|
if (currentWorkflow && currentWorkflow.mode !== "cloud") {
|
|
132
132
|
notify(ctx, `当前会话已有 ${currentWorkflow.mode} workflow。请新建 session 后再启动 HWCode Cloud。`, "warning");
|
|
@@ -10,7 +10,7 @@ import { notify } from "../../lib/extension-ui.ts";
|
|
|
10
10
|
import {
|
|
11
11
|
canonicalizeWorkspaceRoot,
|
|
12
12
|
} from "../../lib/workflow-guard.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { getSessionProjectRoot } from "../../lib/working-directory.ts";
|
|
14
14
|
import {
|
|
15
15
|
WORKFLOW_STATE_TYPE,
|
|
16
16
|
activeWorkflow,
|
|
@@ -94,7 +94,7 @@ export function registerSddWorkflow(pi: ExtensionAPI) {
|
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
const root = canonicalizeWorkspaceRoot(
|
|
97
|
+
const root = canonicalizeWorkspaceRoot(getSessionProjectRoot(ctx.sessionManager));
|
|
98
98
|
const existing = activeWorkflow(ctx.sessionManager.getEntries());
|
|
99
99
|
if (existing) {
|
|
100
100
|
notify(
|
|
@@ -6,7 +6,7 @@ import { notify } from "../../lib/extension-ui.ts";
|
|
|
6
6
|
import {
|
|
7
7
|
canonicalizeWorkspaceRoot,
|
|
8
8
|
} from "../../lib/workflow-guard.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { getSessionProjectRoot } from "../../lib/working-directory.ts";
|
|
10
10
|
import {
|
|
11
11
|
WORKFLOW_STATE_TYPE,
|
|
12
12
|
activeWorkflow,
|
|
@@ -24,7 +24,7 @@ export function registerVibeWorkflow(pi: ExtensionAPI) {
|
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const root = canonicalizeWorkspaceRoot(
|
|
27
|
+
const root = canonicalizeWorkspaceRoot(getSessionProjectRoot(ctx.sessionManager));
|
|
28
28
|
const existing = activeWorkflow(ctx.sessionManager.getEntries());
|
|
29
29
|
if (existing) {
|
|
30
30
|
notify(
|
|
@@ -7,7 +7,7 @@ import { notify } from "../../lib/extension-ui.ts";
|
|
|
7
7
|
import {
|
|
8
8
|
canonicalizeWorkspaceRoot,
|
|
9
9
|
} from "../../lib/workflow-guard.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { getSessionProjectRoot } from "../../lib/working-directory.ts";
|
|
11
11
|
import {
|
|
12
12
|
WORKFLOW_EXTERNAL_AUDIT_TYPE,
|
|
13
13
|
WORKFLOW_STATE_TYPE,
|
|
@@ -47,13 +47,13 @@ export function registerWorkspaceGuard(pi: ExtensionAPI) {
|
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const currentRoot = canonicalizeWorkspaceRoot(
|
|
50
|
+
const currentRoot = canonicalizeWorkspaceRoot(getSessionProjectRoot(ctx.sessionManager));
|
|
51
51
|
if (currentRoot !== restored.root) {
|
|
52
52
|
activeState = undefined;
|
|
53
53
|
pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updateWorkflowState(restored, {
|
|
54
54
|
status: "cancelled",
|
|
55
55
|
phase: "root-changed",
|
|
56
|
-
reason: "Stored workflow root no longer matches the
|
|
56
|
+
reason: "Stored workflow root no longer matches the immutable session project root.",
|
|
57
57
|
}));
|
|
58
58
|
notify(ctx, "Stored HWCode workflow disabled because the current directory changed.", "warning");
|
|
59
59
|
return;
|
|
@@ -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
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { resolve
|
|
3
|
+
import { resolve } 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 {
|
|
10
|
+
import { canonicalizeDirectory } from "../working-directory.ts";
|
|
11
11
|
import { knowledgeDeltaDigest } from "./extractor.ts";
|
|
12
12
|
import { sanitizeKnowledgeText } from "./sanitize.ts";
|
|
13
13
|
import { projectKnowledgeKey } from "./store.ts";
|
|
@@ -22,6 +22,9 @@ interface ReviewPiece {
|
|
|
22
22
|
|
|
23
23
|
const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
|
|
24
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;
|
|
25
28
|
|
|
26
29
|
function hash(value: string, length = 64): string {
|
|
27
30
|
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
@@ -105,24 +108,98 @@ function buildValidationContext(entries: SessionEntry[]): string {
|
|
|
105
108
|
.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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;
|
|
120
188
|
}
|
|
121
|
-
|
|
122
|
-
|
|
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; }
|
|
123
194
|
}
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
|
|
201
|
+
function projectRootForSession(header: SessionHeader): string {
|
|
202
|
+
try { return canonicalizeDirectory(header.cwd); } catch { return resolve(header.cwd); }
|
|
126
203
|
}
|
|
127
204
|
|
|
128
205
|
export function discoverSessionFiles(root: string): string[] {
|
|
@@ -164,18 +241,21 @@ export function readReviewTask(
|
|
|
164
241
|
const pending = branch.slice(cursorIndex + 1);
|
|
165
242
|
if (pending.length === 0) return undefined;
|
|
166
243
|
const delta = buildDelta(pending);
|
|
244
|
+
const loadedIds = loadedKnowledgeIds(pending);
|
|
245
|
+
const recallQuery = buildRecallQuery(pending, loadedIds);
|
|
167
246
|
const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
|
|
168
247
|
const firstEntryId = pending[0].id;
|
|
169
248
|
const lastEntryId = pending[pending.length - 1].id;
|
|
170
|
-
const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
|
|
249
|
+
const deltaDigest = knowledgeDeltaDigest(`${delta || `${firstEntryId}\0${lastEntryId}`}\0${recallQuery}\0${loadedIds.join("\0")}`);
|
|
171
250
|
const sessionFileHash = hash(resolve(sessionFile), 16);
|
|
172
251
|
const sessionKey = hash(header.id, 24);
|
|
173
252
|
const reviewKey = hash(`${header.id}\0${firstEntryId}\0${lastEntryId}\0${deltaDigest}`);
|
|
174
|
-
const projectRoot = projectRootForSession(header
|
|
253
|
+
const projectRoot = projectRootForSession(header);
|
|
175
254
|
return {
|
|
176
255
|
requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
|
|
177
256
|
projectRoot, projectKey: projectKnowledgeKey(projectRoot),
|
|
178
|
-
firstEntryId, lastEntryId, context, delta,
|
|
257
|
+
firstEntryId, lastEntryId, context, delta, recallQuery, loadedKnowledgeIds: loadedIds,
|
|
258
|
+
deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
|
|
179
259
|
};
|
|
180
260
|
}
|
|
181
261
|
|
|
@@ -19,6 +19,12 @@ const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
|
|
|
19
19
|
const SAFE_ID_RE = /^[a-z0-9][a-z0-9-]{0,79}$/u;
|
|
20
20
|
const SAFE_GENERATION_RE = /^[0-9TZ-]+-[a-f0-9]{12}$/u;
|
|
21
21
|
const PROJECT_SCOPE_RE = /^project:[a-f0-9]{16}$/u;
|
|
22
|
+
const UNVERIFIED_CLAIM_RE = /(?:待验证|尚未(?:验证|确认)|未经(?:验证|确认)|未(?:经)?证实|推测|猜测|疑似|可能是|可能由于|可能导致|或许|unverified|unconfirmed|speculat(?:e|ive|ion)|hypothes(?:is|ize)|suspect(?:ed|ion)?|possibly|probably)/iu;
|
|
23
|
+
const VOLATILE_ARGUMENT_RE = /--(?:namespace|name|server_name|security_group_id|subnet_id|vpc_id|image_id|project_id)(?:\.\d+)?=(?!<[^>]+>|\$?\{)[^\s`"']+/iu;
|
|
24
|
+
const DISTINCTIVE_COMMON_TERMS = new Set([
|
|
25
|
+
"cloud", "huawei", "huaweicloud", "hcloud", "topic", "rule", "sop", "project", "region", "create",
|
|
26
|
+
"deploy", "deployment", "service", "workflow", "cn", "south",
|
|
27
|
+
]);
|
|
22
28
|
|
|
23
29
|
export class KnowledgeCommitBusyError extends Error {}
|
|
24
30
|
|
|
@@ -138,8 +144,14 @@ function semanticDuplicateScore(candidate: KnowledgeCandidate, entry: KnowledgeC
|
|
|
138
144
|
comparisonTerms(candidate.keywords.join(" ")),
|
|
139
145
|
comparisonTerms(entry.keywords.join(" ")),
|
|
140
146
|
);
|
|
141
|
-
if (titleScore
|
|
142
|
-
|
|
147
|
+
if (titleScore >= 0.8 && keywordScore >= 0.5) return titleScore * 0.7 + keywordScore * 0.3;
|
|
148
|
+
const candidateTerms = comparisonTerms(`${candidate.title} ${candidate.keywords.join(" ")}`);
|
|
149
|
+
const entryTerms = comparisonTerms(`${entry.title} ${entry.keywords.join(" ")}`);
|
|
150
|
+
const distinctiveShared = [...candidateTerms].filter((term) => (
|
|
151
|
+
entryTerms.has(term) && term.length >= 3 && !DISTINCTIVE_COMMON_TERMS.has(term)
|
|
152
|
+
)).length;
|
|
153
|
+
if (keywordScore < 0.5 || distinctiveShared < 3) return 0;
|
|
154
|
+
return keywordScore * 0.7 + Math.min(distinctiveShared / 6, 1) * 0.3;
|
|
143
155
|
}
|
|
144
156
|
|
|
145
157
|
function semanticDuplicateIndex(candidate: KnowledgeCandidate, catalog: KnowledgeCatalog): number {
|
|
@@ -191,7 +203,23 @@ function asStringArray(value: unknown, maxItems: number): string[] {
|
|
|
191
203
|
|
|
192
204
|
type CandidateNormalization = { candidate: KnowledgeCandidate } | { reason: KnowledgeSkipReason };
|
|
193
205
|
|
|
194
|
-
function
|
|
206
|
+
function withoutLeadingTitle(body: string): string {
|
|
207
|
+
return body.replace(/^#\s+[^\n]*(?:\n+|$)/u, "").trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function containsVolatileDetail(body: string, projectRoot: string): boolean {
|
|
211
|
+
const projectName = basename(resolve(projectRoot));
|
|
212
|
+
const escapedProjectName = projectName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
213
|
+
const namedProject = projectName.length >= 5
|
|
214
|
+
&& new RegExp(`(?:项目|project)\\s*(?:[::=]|is)?\\s*[\`'\"]?${escapedProjectName}(?![\\p{L}\\p{N}-])`, "iu").test(body);
|
|
215
|
+
return VOLATILE_ARGUMENT_RE.test(body)
|
|
216
|
+
|| namedProject
|
|
217
|
+
|| /\.hwcode\/cloud\/runs\//iu.test(body);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeCandidate(
|
|
221
|
+
value: unknown, projectKey: string, projectRoot: string, validationText: string,
|
|
222
|
+
): CandidateNormalization {
|
|
195
223
|
if (!value || typeof value !== "object") return { reason: "invalid-schema" };
|
|
196
224
|
const raw = value as Record<string, unknown>;
|
|
197
225
|
if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
|
|
@@ -202,9 +230,12 @@ function normalizeCandidate(value: unknown, projectKey: string, validationText:
|
|
|
202
230
|
&& (typeof raw.targetId !== "string" || !SAFE_ID_RE.test(raw.targetId))) return { reason: "unknown-target" };
|
|
203
231
|
const explicitUserDirective = raw.explicitUserDirective === true;
|
|
204
232
|
const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
|
|
205
|
-
const body = compactKnowledgeText(raw.body, 20_000);
|
|
233
|
+
const body = withoutLeadingTitle(compactKnowledgeText(raw.body, 20_000));
|
|
206
234
|
const evidence = asStringArray(raw.evidence, 8);
|
|
207
235
|
if (!body || evidence.length === 0) return { reason: "missing-body-or-evidence" };
|
|
236
|
+
const candidateClaims = `${raw.title}\n${raw.summary}\n${body}`;
|
|
237
|
+
if (!explicitUserDirective && UNVERIFIED_CLAIM_RE.test(candidateClaims)) return { reason: "unverified-claim" };
|
|
238
|
+
if (containsVolatileDetail(body, projectRoot)) return { reason: "volatile-detail" };
|
|
208
239
|
if (contradictsVerifiedFailures(body, validationText)) return { reason: "contradicts-verified-failure" };
|
|
209
240
|
const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
|
|
210
241
|
const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
|
|
@@ -360,7 +391,9 @@ export function commitKnowledgeReview(
|
|
|
360
391
|
const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
|
|
361
392
|
const rejected: Array<{ reason: KnowledgeSkipReason; value: unknown }> = [];
|
|
362
393
|
for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
|
|
363
|
-
const normalized = normalizeCandidate(
|
|
394
|
+
const normalized = normalizeCandidate(
|
|
395
|
+
value, task.projectKey, task.projectRoot, `${task.context ?? ""}\n${task.delta}`,
|
|
396
|
+
);
|
|
364
397
|
if (!("candidate" in normalized)) {
|
|
365
398
|
recordSkip(result, normalized.reason);
|
|
366
399
|
rejected.push({ reason: normalized.reason, value });
|
|
@@ -81,6 +81,8 @@ export interface PersistKnowledgeResult {
|
|
|
81
81
|
export type KnowledgeSkipReason =
|
|
82
82
|
| "invalid-schema"
|
|
83
83
|
| "unstable"
|
|
84
|
+
| "unverified-claim"
|
|
85
|
+
| "volatile-detail"
|
|
84
86
|
| "low-confidence"
|
|
85
87
|
| "missing-body-or-evidence"
|
|
86
88
|
| "contradicts-verified-failure"
|
|
@@ -98,6 +100,8 @@ export interface KnowledgeReviewTask {
|
|
|
98
100
|
lastEntryId: string;
|
|
99
101
|
context?: string;
|
|
100
102
|
delta: string;
|
|
103
|
+
recallQuery?: string;
|
|
104
|
+
loadedKnowledgeIds?: string[];
|
|
101
105
|
deltaDigest: string;
|
|
102
106
|
fileSize: number;
|
|
103
107
|
fileMtimeMs: number;
|
|
@@ -69,6 +69,11 @@ export function getWorkingDirectory(source: SessionDirectorySource): string {
|
|
|
69
69
|
return getWorkingDirectoryState(source).cwd;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** The immutable project root captured when the session was created. */
|
|
73
|
+
export function getSessionProjectRoot(source: Pick<SessionDirectorySource, "getCwd">): string {
|
|
74
|
+
return canonicalizeDirectory(source.getCwd());
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
export function setWorkingDirectoryState(
|
|
73
78
|
source: SessionDirectorySource,
|
|
74
79
|
state: WorkingDirectoryState,
|