kibi-agent-core 0.2.1 → 0.3.1
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/CHANGELOG.md +16 -0
- package/dist/knowledge-index.d.ts +30 -2
- package/dist/knowledge-index.js +315 -31
- package/dist/snippets.d.ts +26 -3
- package/dist/snippets.js +97 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# kibi-agent-core
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- dbfe490: Claude Code: when the agent reads a requirement-linked file and then edits it, the edit hook now still says what the lead requirement must keep true (its linked facts and decision) and names the `kb_check` impact call, alongside the symbol the edit lands in; before, the earlier read suppressed everything but that one line, so an agent that read first never saw the facts or the check. A `SessionStart` after a compact or clear resets the session's "already shown" memory, so the next read or edit of a linked file shows its snippet again instead of returning nothing into a context that no longer holds it; a resume keeps the memory. Codex: an `apply_patch` (or `Write`/`Edit`) to a source file no symbol links to a requirement now gets the same one-time discovery note as Claude Code, with the `kb_search` call that finds requirements for it (an unreadable manifest is reported once instead), and `Stop` now reminds of the `kb_check` impact call after `apply_patch` edits, which were not tracked because the patch names its files only in the patch text.
|
|
8
|
+
|
|
9
|
+
Technical summary: `kibi-agent-core/snippets` gains `editKnowledgeUpdate` (focus line, `requirementGroundingLines`, impact-check line), the shared `unownedSourceNote` and `unreadableManifestNote` (re-exported by `kibi-claude`), and an optional `noteFor` callback on `editKnowledgeContext` that claims the same once-per-file slot for a file without a snippet. The Claude session journal gets a `context_reset` event that clears the shown, notice and explored sets but keeps pending edits and their reminders; `dispatchHook` appends it for `source: "compact" | "clear"`. The Codex runner passes `noteFor` from the knowledge index's manifest state and tracks dirty paths (and direct `.kb/` edits) through `extractEditedPaths`, which reads `apply_patch` headers.
|
|
10
|
+
|
|
11
|
+
## 0.3.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- e00adf2: Projects set up with kibi-cli before 2.18.0 keep every symbol on one flow-sequence line of `.kb/symbols.yaml`, and the host hooks read none of it: the Claude Code hooks told agents that no symbol in a linked file was tied to a requirement, on every edit, when the opposite was true. The shared hook scanner now reads flow-layout manifests like block ones, the Claude Code edit hook no longer calls a file unowned when the manifest is unreadable or when the coordinate artifact binds a symbol to it that the scanner could not read (it says once per session that the manifest could not be read instead), and a plain `kibi sync` rewrites such a manifest in block layout once, keeping comments and order.
|
|
16
|
+
|
|
17
|
+
Technical summary: `kibi-agent-core` `knowledge-index` gains a flow-collection reader for `symbols: [ … ]` sections (single or multi-line, CRLF safe) and for flow values inside block records, the index reports `manifest` (`missing` | `empty` | `indexed` | `unreadable`), `coordinateFiles` and `unlinkedFiles`, and the cache format moves to v2 so stale indexes rebuild. `kibi-claude` adds `unreadableManifestNote` (once per session) in place of `unownedSourceNote` for an unreadable manifest or a coordinate-bound file with no indexed record; the Claude and Codex hook bundles are rebuilt. `kibi sync` calls `normalizeManifestLayout` on every tracked symbol manifest ahead of its no-op fast path, through the same `blockifySymbolManifest` helper `kb_upsert`'s manifest writer uses.
|
|
18
|
+
|
|
3
19
|
## 0.2.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
|
@@ -13,10 +13,30 @@ export type IndexedSymbol = {
|
|
|
13
13
|
coveredBy: string[];
|
|
14
14
|
executableFor: string[];
|
|
15
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* What the scanner made of `.kb/symbols.yaml`: `missing` (no file), `empty`
|
|
18
|
+
* (no symbol records, such as the `symbols: []` that `kibi init` writes),
|
|
19
|
+
* `indexed` (records were read) or `unreadable` (the file has content the
|
|
20
|
+
* line scanner could not turn into a single record, so an empty index must
|
|
21
|
+
* not be read as "nothing is linked").
|
|
22
|
+
*/
|
|
23
|
+
export type ManifestState = "missing" | "empty" | "indexed" | "unreadable";
|
|
16
24
|
export type KnowledgeIndex = {
|
|
17
25
|
/** Workspace-relative source file -> symbols with at least one relationship. */
|
|
18
26
|
files: Record<string, IndexedSymbol[]>;
|
|
19
27
|
symbolCount: number;
|
|
28
|
+
manifest: ManifestState;
|
|
29
|
+
/**
|
|
30
|
+
* Workspace-relative source files that `.kb/symbol-coordinates.yaml` binds
|
|
31
|
+
* a symbol to, whether or not the manifest record for it could be indexed.
|
|
32
|
+
*/
|
|
33
|
+
coordinateFiles: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Workspace-relative source files that have a manifest record but no
|
|
36
|
+
* symbol with a relationship, so an edit hook can tell "not linked yet"
|
|
37
|
+
* from "the record could not be read".
|
|
38
|
+
*/
|
|
39
|
+
unlinkedFiles: string[];
|
|
20
40
|
};
|
|
21
41
|
type RawRecord = Record<string, unknown>;
|
|
22
42
|
/**
|
|
@@ -27,11 +47,17 @@ export declare function scanSymbolsManifest(text: string): RawRecord[];
|
|
|
27
47
|
export type Coordinates = Record<string, {
|
|
28
48
|
line?: number;
|
|
29
49
|
endLine?: number;
|
|
50
|
+
sourceFile?: string;
|
|
30
51
|
}>;
|
|
31
|
-
/** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine`. */
|
|
52
|
+
/** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine` / `sourceFile`. */
|
|
32
53
|
export declare function scanSymbolCoordinates(text: string): Coordinates;
|
|
54
|
+
/**
|
|
55
|
+
* Manifest state for a scan result: `unreadable` when the file holds content
|
|
56
|
+
* beyond comments and an empty `symbols` value yet yielded no record.
|
|
57
|
+
*/
|
|
58
|
+
export declare function manifestStateOf(text: string | undefined, recordCount: number): ManifestState;
|
|
33
59
|
/** Collapse raw manifest records and coordinates into the file-keyed index. */
|
|
34
|
-
export declare function buildKnowledgeIndex(records: readonly RawRecord[], coordinates?: Coordinates, options?: KnowledgeIndexOptions): KnowledgeIndex;
|
|
60
|
+
export declare function buildKnowledgeIndex(records: readonly RawRecord[], coordinates?: Coordinates, options?: KnowledgeIndexOptions, manifest?: ManifestState): KnowledgeIndex;
|
|
35
61
|
/**
|
|
36
62
|
* Load the index for a workspace, reusing the cache in `cacheDir` while both
|
|
37
63
|
* manifests keep their size and mtime.
|
|
@@ -45,6 +71,8 @@ export type EntitySummary = {
|
|
|
45
71
|
id: string;
|
|
46
72
|
title?: string;
|
|
47
73
|
status?: string;
|
|
74
|
+
/** Requirement or ADR firmness as authored (absent reads as default). */
|
|
75
|
+
firmness?: string;
|
|
48
76
|
/**
|
|
49
77
|
* Frontmatter `links` (plain string entries read as `relates_to`) merged
|
|
50
78
|
* with canonical relationship-shard records whose `from` is this entity.
|
package/dist/knowledge-index.js
CHANGED
|
@@ -13,14 +13,17 @@ import path from "node:path";
|
|
|
13
13
|
* file plus a JSON parse.
|
|
14
14
|
*
|
|
15
15
|
* The scanner understands the subset the Kibi writer emits (block mappings and
|
|
16
|
-
* sequences, quoted scalars, no line folding)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* sequences, quoted scalars, no line folding), flow collections wherever a
|
|
17
|
+
* value starts with `[` or `{` (hand-authored `links: [REQ-001]`, typed
|
|
18
|
+
* `relationships: [{type: implements, target: REQ-1}]`), and a whole manifest
|
|
19
|
+
* written as one flow sequence: kibi-cli before 2.18.0 appended to the
|
|
20
|
+
* `symbols: []` that `kibi init` writes and kept every symbol on that line.
|
|
21
|
+
* The manifest is a hint for progressive disclosure, not the source of truth:
|
|
22
|
+
* snippets always point at kb_query for the authoritative view.
|
|
20
23
|
*/
|
|
21
24
|
const SYMBOLS_MANIFEST = ".kb/symbols.yaml";
|
|
22
25
|
const SYMBOL_COORDINATES = ".kb/symbol-coordinates.yaml";
|
|
23
|
-
const INDEX_FORMAT = "kibi-agent-core.knowledge-index.
|
|
26
|
+
const INDEX_FORMAT = "kibi-agent-core.knowledge-index.v2";
|
|
24
27
|
const CACHE_FILE = "knowledge-index.json";
|
|
25
28
|
function unquote(raw) {
|
|
26
29
|
const value = raw.trim();
|
|
@@ -39,23 +42,157 @@ function unquote(raw) {
|
|
|
39
42
|
const comment = value.search(/\s#/);
|
|
40
43
|
return comment >= 0 ? value.slice(0, comment).trimEnd() : value;
|
|
41
44
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
// implements REQ-kibi-hooks-flow-manifest
|
|
46
|
+
function parseFlow(text, start = 0) {
|
|
47
|
+
let pos = start;
|
|
48
|
+
const skip = () => {
|
|
49
|
+
while (pos < text.length) {
|
|
50
|
+
const char = text[pos];
|
|
51
|
+
if (char === "#") {
|
|
52
|
+
while (pos < text.length && text[pos] !== "\n")
|
|
53
|
+
pos += 1;
|
|
54
|
+
}
|
|
55
|
+
else if (char === " " ||
|
|
56
|
+
char === "\t" ||
|
|
57
|
+
char === "\n" ||
|
|
58
|
+
char === "\r") {
|
|
59
|
+
pos += 1;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const scalar = (stops) => {
|
|
67
|
+
const quote = text[pos];
|
|
68
|
+
if (quote === '"' || quote === "'") {
|
|
69
|
+
let cursor = pos + 1;
|
|
70
|
+
while (cursor < text.length) {
|
|
71
|
+
if (quote === '"' && text[cursor] === "\\")
|
|
72
|
+
cursor += 2;
|
|
73
|
+
else if (text[cursor] === quote) {
|
|
74
|
+
if (quote === "'" && text[cursor + 1] === "'")
|
|
75
|
+
cursor += 2;
|
|
76
|
+
else
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
cursor += 1;
|
|
81
|
+
}
|
|
82
|
+
if (cursor >= text.length)
|
|
83
|
+
return undefined;
|
|
84
|
+
const raw = text.slice(pos, cursor + 1);
|
|
85
|
+
pos = cursor + 1;
|
|
86
|
+
return unquote(raw);
|
|
87
|
+
}
|
|
88
|
+
let cursor = pos;
|
|
89
|
+
while (cursor < text.length) {
|
|
90
|
+
const char = text[cursor] ?? "";
|
|
91
|
+
if ((stops.includes(char) && char !== ":") ||
|
|
92
|
+
char === "\n" ||
|
|
93
|
+
char === "\r")
|
|
94
|
+
break;
|
|
95
|
+
// A plain mapping key ends at the first `: ` (or `:` before a stop).
|
|
96
|
+
if (stops.includes(":") &&
|
|
97
|
+
char === ":" &&
|
|
98
|
+
(cursor + 1 >= text.length || /[\s,\]}]/.test(text[cursor + 1] ?? "")))
|
|
99
|
+
break;
|
|
100
|
+
if (char === "#" && /\s/.test(text[cursor - 1] ?? ""))
|
|
101
|
+
break;
|
|
102
|
+
cursor += 1;
|
|
103
|
+
}
|
|
104
|
+
const raw = text.slice(pos, cursor).trim();
|
|
105
|
+
pos = cursor;
|
|
106
|
+
return raw;
|
|
107
|
+
};
|
|
108
|
+
const value = () => {
|
|
109
|
+
skip();
|
|
110
|
+
const open = text[pos];
|
|
111
|
+
if (open === "[") {
|
|
112
|
+
pos += 1;
|
|
113
|
+
const items = [];
|
|
114
|
+
for (;;) {
|
|
115
|
+
skip();
|
|
116
|
+
if (text[pos] === "]") {
|
|
117
|
+
pos += 1;
|
|
118
|
+
return items;
|
|
119
|
+
}
|
|
120
|
+
if (pos >= text.length)
|
|
121
|
+
throw new SyntaxError("unterminated flow sequence");
|
|
122
|
+
const item = value();
|
|
123
|
+
if (item !== undefined)
|
|
124
|
+
items.push(item);
|
|
125
|
+
skip();
|
|
126
|
+
if (text[pos] === ",")
|
|
127
|
+
pos += 1;
|
|
128
|
+
else if (text[pos] !== "]")
|
|
129
|
+
throw new SyntaxError("expected , or ]");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (open === "{") {
|
|
133
|
+
pos += 1;
|
|
134
|
+
const record = {};
|
|
135
|
+
for (;;) {
|
|
136
|
+
skip();
|
|
137
|
+
if (text[pos] === "}") {
|
|
138
|
+
pos += 1;
|
|
139
|
+
return record;
|
|
140
|
+
}
|
|
141
|
+
if (pos >= text.length)
|
|
142
|
+
throw new SyntaxError("unterminated flow mapping");
|
|
143
|
+
const key = scalar(":,}");
|
|
144
|
+
if (key === undefined)
|
|
145
|
+
throw new SyntaxError("unterminated key");
|
|
146
|
+
skip();
|
|
147
|
+
let entry;
|
|
148
|
+
if (text[pos] === ":") {
|
|
149
|
+
pos += 1;
|
|
150
|
+
skip();
|
|
151
|
+
entry = text[pos] === "," || text[pos] === "}" ? undefined : value();
|
|
152
|
+
}
|
|
153
|
+
if (key.length > 0)
|
|
154
|
+
record[key] = entry;
|
|
155
|
+
skip();
|
|
156
|
+
if (text[pos] === ",")
|
|
157
|
+
pos += 1;
|
|
158
|
+
else if (text[pos] !== "}")
|
|
159
|
+
throw new SyntaxError("expected , or }");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const raw = scalar(",]}");
|
|
163
|
+
if (raw === undefined)
|
|
164
|
+
throw new SyntaxError("unterminated scalar");
|
|
165
|
+
return raw;
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
const parsed = value();
|
|
169
|
+
return { value: parsed, end: pos };
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
45
172
|
return undefined;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.filter((item) => item.length > 0);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** True when `raw` opens a flow collection the rest of the line does not close. */
|
|
176
|
+
function opensFlow(raw) {
|
|
177
|
+
const value = raw.trim();
|
|
178
|
+
return value.startsWith("[") || value.startsWith("{");
|
|
53
179
|
}
|
|
54
180
|
const keyValue = /^([A-Za-z0-9_"'-][^:]*?):(?:\s+(.*))?$/;
|
|
181
|
+
/** The flow value `raw` holds, or undefined when it is not a complete one. */
|
|
182
|
+
function completeFlowValue(raw) {
|
|
183
|
+
if (!opensFlow(raw))
|
|
184
|
+
return undefined;
|
|
185
|
+
const parsed = parseFlow(raw);
|
|
186
|
+
if (parsed === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
const rest = raw.slice(parsed.end).trim();
|
|
189
|
+
// `[WIP] fix rounding` is a plain scalar that merely starts with `[`.
|
|
190
|
+
return rest.length === 0 || rest.startsWith("#") ? parsed : undefined;
|
|
191
|
+
}
|
|
55
192
|
function parseScalarOrFlow(raw) {
|
|
56
193
|
if (raw === undefined || raw.trim().length === 0)
|
|
57
194
|
return undefined;
|
|
58
|
-
return
|
|
195
|
+
return completeFlowValue(raw)?.value ?? unquote(raw);
|
|
59
196
|
}
|
|
60
197
|
/**
|
|
61
198
|
* Scan the symbols manifest into raw records. Accepts both the canonical
|
|
@@ -64,11 +201,65 @@ function parseScalarOrFlow(raw) {
|
|
|
64
201
|
export function scanSymbolsManifest(text) {
|
|
65
202
|
return scanRecordSequence(text, "symbols");
|
|
66
203
|
}
|
|
204
|
+
function unbalancedFlow(raw) {
|
|
205
|
+
return opensFlow(raw) && parseFlow(raw) === undefined;
|
|
206
|
+
}
|
|
207
|
+
/** Records of a manifest whose `<sectionKey>:` value (or whole root) is a flow sequence. */
|
|
208
|
+
function flowRecords(text, start) {
|
|
209
|
+
const parsed = parseFlow(text, start);
|
|
210
|
+
if (!Array.isArray(parsed?.value))
|
|
211
|
+
return undefined;
|
|
212
|
+
return parsed.value.filter((item) => item !== null && typeof item === "object" && !Array.isArray(item));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Records of a manifest whose `<sectionKey>:` value (or whole root) is a flow
|
|
216
|
+
* sequence, possibly spanning several lines; undefined when the section is
|
|
217
|
+
* block-style or absent.
|
|
218
|
+
*/
|
|
219
|
+
// implements REQ-kibi-hooks-flow-manifest
|
|
220
|
+
function scanFlowSection(text, sectionKey) {
|
|
221
|
+
const escapedKey = sectionKey.replaceAll(/[.*+?^$(){}|[\]\\]/g, "\\$&");
|
|
222
|
+
const header = new RegExp(`^${escapedKey}:[ \\t]*(\\[)?`);
|
|
223
|
+
let offset = 0;
|
|
224
|
+
let first = true;
|
|
225
|
+
let afterHeader = false;
|
|
226
|
+
// Keep the separators so offsets stay exact for CRLF files.
|
|
227
|
+
for (const piece of text.split(/(\r?\n)/)) {
|
|
228
|
+
const content = piece.trim();
|
|
229
|
+
const indent = piece.length - piece.trimStart().length;
|
|
230
|
+
if (content.length > 0 && !content.startsWith("#")) {
|
|
231
|
+
if (afterHeader) {
|
|
232
|
+
// `symbols:` with its value on a later line: flow only when that
|
|
233
|
+
// line opens the sequence.
|
|
234
|
+
return content.startsWith("[")
|
|
235
|
+
? flowRecords(text, offset + piece.indexOf("["))
|
|
236
|
+
: undefined;
|
|
237
|
+
}
|
|
238
|
+
if (first && content.startsWith("[")) {
|
|
239
|
+
return flowRecords(text, offset + piece.indexOf("["));
|
|
240
|
+
}
|
|
241
|
+
first = false;
|
|
242
|
+
if (indent === 0) {
|
|
243
|
+
const match = header.exec(piece);
|
|
244
|
+
if (match) {
|
|
245
|
+
if (match[1] !== undefined)
|
|
246
|
+
return flowRecords(text, offset + piece.indexOf("["));
|
|
247
|
+
afterHeader = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
offset += piece.length;
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
67
255
|
/**
|
|
68
256
|
* Scan the block sequence under top-level `sectionKey` (or a bare root
|
|
69
257
|
* sequence) into raw records.
|
|
70
258
|
*/
|
|
71
259
|
function scanRecordSequence(text, sectionKey) {
|
|
260
|
+
const flowSection = scanFlowSection(text, sectionKey);
|
|
261
|
+
if (flowSection !== undefined)
|
|
262
|
+
return flowSection;
|
|
72
263
|
const records = [];
|
|
73
264
|
let itemIndent;
|
|
74
265
|
let keyIndent = 0;
|
|
@@ -78,12 +269,31 @@ function scanRecordSequence(text, sectionKey) {
|
|
|
78
269
|
// Top-level section: records are the `<sectionKey>:` sequence or a bare
|
|
79
270
|
// root sequence; sequences under any other top-level key are ignored.
|
|
80
271
|
let section = "root";
|
|
272
|
+
// A flow value (`relationships: [` …) that the writer wrapped over several
|
|
273
|
+
// lines is collected until its brackets balance, then parsed as one value.
|
|
274
|
+
let pendingFlow;
|
|
81
275
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
82
276
|
const content = rawLine.trim();
|
|
83
277
|
if (content.length === 0 || content.startsWith("#"))
|
|
84
278
|
continue;
|
|
85
279
|
const indent = rawLine.length - rawLine.trimStart().length;
|
|
86
280
|
const dash = /^(\s*)-(\s+)(.*)$/.exec(rawLine);
|
|
281
|
+
if (pendingFlow !== undefined && current !== undefined) {
|
|
282
|
+
const target = pendingFlow.onListItem && listItem !== undefined ? listItem : current;
|
|
283
|
+
if (indent > (itemIndent ?? 0) && !(dash && indent === itemIndent)) {
|
|
284
|
+
pendingFlow.text += `\n${content}`;
|
|
285
|
+
const parsed = parseFlow(pendingFlow.text);
|
|
286
|
+
if (parsed === undefined)
|
|
287
|
+
continue;
|
|
288
|
+
target[pendingFlow.key] = parsed.value;
|
|
289
|
+
pendingFlow = undefined;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// The next record or section started before the brackets balanced:
|
|
293
|
+
// keep the opening line as a plain scalar and read on.
|
|
294
|
+
target[pendingFlow.key] = unquote(pendingFlow.first);
|
|
295
|
+
pendingFlow = undefined;
|
|
296
|
+
}
|
|
87
297
|
if (indent === 0 && !dash) {
|
|
88
298
|
const pair = keyValue.exec(content);
|
|
89
299
|
section =
|
|
@@ -105,11 +315,21 @@ function scanRecordSequence(text, sectionKey) {
|
|
|
105
315
|
listItem = undefined;
|
|
106
316
|
const pair = keyValue.exec(dash[3] ?? "");
|
|
107
317
|
if (pair?.[1] !== undefined) {
|
|
318
|
+
const key = unquote(pair[1]);
|
|
319
|
+
if (pair[2] !== undefined && unbalancedFlow(pair[2])) {
|
|
320
|
+
pendingFlow = {
|
|
321
|
+
key,
|
|
322
|
+
first: pair[2],
|
|
323
|
+
text: pair[2],
|
|
324
|
+
onListItem: false,
|
|
325
|
+
};
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
108
328
|
const value = parseScalarOrFlow(pair[2]);
|
|
109
329
|
if (value !== undefined)
|
|
110
|
-
current[
|
|
330
|
+
current[key] = value;
|
|
111
331
|
else
|
|
112
|
-
listKey =
|
|
332
|
+
listKey = key;
|
|
113
333
|
}
|
|
114
334
|
continue;
|
|
115
335
|
}
|
|
@@ -124,14 +344,21 @@ function scanRecordSequence(text, sectionKey) {
|
|
|
124
344
|
? current[listKey]
|
|
125
345
|
: [];
|
|
126
346
|
current[listKey] = list;
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
347
|
+
const item = dash[3] ?? "";
|
|
348
|
+
const flowItem = completeFlowValue(item);
|
|
349
|
+
const pair = flowItem === undefined ? keyValue.exec(item) : null;
|
|
350
|
+
if (flowItem !== undefined) {
|
|
351
|
+
// `- { type: implements, target: REQ-1 }`
|
|
352
|
+
listItem = undefined;
|
|
353
|
+
list.push(flowItem.value);
|
|
354
|
+
}
|
|
355
|
+
else if (pair?.[1] !== undefined && pair[2] !== undefined) {
|
|
356
|
+
listItem = { [unquote(pair[1])]: parseScalarOrFlow(pair[2]) };
|
|
130
357
|
list.push(listItem);
|
|
131
358
|
}
|
|
132
359
|
else {
|
|
133
360
|
listItem = undefined;
|
|
134
|
-
list.push(unquote(
|
|
361
|
+
list.push(unquote(item));
|
|
135
362
|
}
|
|
136
363
|
continue;
|
|
137
364
|
}
|
|
@@ -141,6 +368,16 @@ function scanRecordSequence(text, sectionKey) {
|
|
|
141
368
|
const key = unquote(pair[1]);
|
|
142
369
|
if (indent === keyIndent) {
|
|
143
370
|
listItem = undefined;
|
|
371
|
+
if (pair[2] !== undefined && unbalancedFlow(pair[2])) {
|
|
372
|
+
pendingFlow = {
|
|
373
|
+
key,
|
|
374
|
+
first: pair[2],
|
|
375
|
+
text: pair[2],
|
|
376
|
+
onListItem: false,
|
|
377
|
+
};
|
|
378
|
+
listKey = undefined;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
144
381
|
const value = parseScalarOrFlow(pair[2]);
|
|
145
382
|
if (value === undefined) {
|
|
146
383
|
listKey = key;
|
|
@@ -151,12 +388,16 @@ function scanRecordSequence(text, sectionKey) {
|
|
|
151
388
|
}
|
|
152
389
|
}
|
|
153
390
|
else if (listItem !== undefined && pair[2] !== undefined) {
|
|
154
|
-
|
|
391
|
+
if (unbalancedFlow(pair[2])) {
|
|
392
|
+
pendingFlow = { key, first: pair[2], text: pair[2], onListItem: true };
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
listItem[key] = parseScalarOrFlow(pair[2]);
|
|
155
396
|
}
|
|
156
397
|
}
|
|
157
398
|
return records;
|
|
158
399
|
}
|
|
159
|
-
/** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine`. */
|
|
400
|
+
/** Scan `coordinates:` -> `<symbol id>:` -> `sourceLine` / `sourceEndLine` / `sourceFile`. */
|
|
160
401
|
export function scanSymbolCoordinates(text) {
|
|
161
402
|
const coordinates = {};
|
|
162
403
|
let inCoordinates = false;
|
|
@@ -188,6 +429,13 @@ export function scanSymbolCoordinates(text) {
|
|
|
188
429
|
if (currentId === undefined || indent < idIndent)
|
|
189
430
|
continue;
|
|
190
431
|
const entry = coordinates[currentId] ?? {};
|
|
432
|
+
if (key === "sourceFile") {
|
|
433
|
+
const sourceFile = normalizeSourceFile(unquote(pair[2] ?? ""));
|
|
434
|
+
if (sourceFile.length > 0)
|
|
435
|
+
entry.sourceFile = sourceFile;
|
|
436
|
+
coordinates[currentId] = entry;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
191
439
|
const numeric = Number.parseInt(unquote(pair[2] ?? ""), 10);
|
|
192
440
|
if (!Number.isFinite(numeric))
|
|
193
441
|
continue;
|
|
@@ -207,18 +455,49 @@ function pushUnique(list, value) {
|
|
|
207
455
|
if (!list.includes(value))
|
|
208
456
|
list.push(value);
|
|
209
457
|
}
|
|
458
|
+
function normalizeSourceFile(value) {
|
|
459
|
+
return value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Manifest state for a scan result: `unreadable` when the file holds content
|
|
463
|
+
* beyond comments and an empty `symbols` value yet yielded no record.
|
|
464
|
+
*/
|
|
465
|
+
// implements REQ-kibi-hooks-flow-manifest
|
|
466
|
+
export function manifestStateOf(text, recordCount) {
|
|
467
|
+
if (text === undefined)
|
|
468
|
+
return "missing";
|
|
469
|
+
if (recordCount > 0)
|
|
470
|
+
return "indexed";
|
|
471
|
+
const meaningful = text
|
|
472
|
+
.split(/\r?\n/)
|
|
473
|
+
.map((line) => line.trim())
|
|
474
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
|
475
|
+
.filter((line) => !/^symbols:\s*(\[\s*\])?$/.test(line))
|
|
476
|
+
.filter((line) => line !== "[]")
|
|
477
|
+
// Document markers and a version stamp do not make a manifest unreadable.
|
|
478
|
+
.filter((line) => !/^(?:---|\.\.\.|%\S.*|version:\s*\S+)$/.test(line));
|
|
479
|
+
return meaningful.length === 0 ? "empty" : "unreadable";
|
|
480
|
+
}
|
|
210
481
|
/** Collapse raw manifest records and coordinates into the file-keyed index. */
|
|
211
|
-
export function buildKnowledgeIndex(records, coordinates = {}, options = {}) {
|
|
482
|
+
export function buildKnowledgeIndex(records, coordinates = {}, options = {}, manifest = records.length > 0 ? "indexed" : "empty") {
|
|
212
483
|
const files = {};
|
|
213
484
|
let symbolCount = 0;
|
|
485
|
+
const recordedFiles = new Set();
|
|
486
|
+
const coordinateFiles = [];
|
|
487
|
+
for (const entry of Object.values(coordinates)) {
|
|
488
|
+
if (entry.sourceFile !== undefined)
|
|
489
|
+
pushUnique(coordinateFiles, entry.sourceFile);
|
|
490
|
+
}
|
|
491
|
+
coordinateFiles.sort();
|
|
214
492
|
for (const record of records) {
|
|
215
493
|
const id = typeof record.id === "string" ? record.id : undefined;
|
|
216
494
|
const sourceFile = typeof record.sourceFile === "string"
|
|
217
|
-
? record.sourceFile
|
|
495
|
+
? normalizeSourceFile(record.sourceFile)
|
|
218
496
|
: undefined;
|
|
219
497
|
if (!id || !sourceFile)
|
|
220
498
|
continue;
|
|
221
499
|
symbolCount += 1;
|
|
500
|
+
recordedFiles.add(sourceFile);
|
|
222
501
|
const symbol = {
|
|
223
502
|
id,
|
|
224
503
|
title: typeof record.title === "string" ? record.title : id,
|
|
@@ -281,7 +560,10 @@ export function buildKnowledgeIndex(records, coordinates = {}, options = {}) {
|
|
|
281
560
|
bucket.push(symbol);
|
|
282
561
|
files[sourceFile] = bucket;
|
|
283
562
|
}
|
|
284
|
-
|
|
563
|
+
const unlinkedFiles = [...recordedFiles]
|
|
564
|
+
.filter((file) => files[file] === undefined)
|
|
565
|
+
.sort();
|
|
566
|
+
return { files, symbolCount, manifest, coordinateFiles, unlinkedFiles };
|
|
285
567
|
}
|
|
286
568
|
function fileSignature(filePath) {
|
|
287
569
|
try {
|
|
@@ -337,9 +619,8 @@ export function loadKnowledgeIndex(workspaceRoot, cacheDir, options = {}) {
|
|
|
337
619
|
}
|
|
338
620
|
}
|
|
339
621
|
const symbolsText = readText(symbolsPath);
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
: buildKnowledgeIndex(scanSymbolsManifest(symbolsText), scanSymbolCoordinates(readText(coordinatesPath) ?? ""), options);
|
|
622
|
+
const records = symbolsText === undefined ? [] : scanSymbolsManifest(symbolsText);
|
|
623
|
+
const index = buildKnowledgeIndex(records, scanSymbolCoordinates(readText(coordinatesPath) ?? ""), options, manifestStateOf(symbolsText, records.length));
|
|
343
624
|
if (cachePath) {
|
|
344
625
|
writeCacheAtomically(cachePath, JSON.stringify({ signature, index }));
|
|
345
626
|
}
|
|
@@ -437,11 +718,14 @@ export function readEntitySummary(workspaceRoot, entityId) {
|
|
|
437
718
|
for (const line of lines.slice(1)) {
|
|
438
719
|
if (line.trim() === "---")
|
|
439
720
|
break;
|
|
440
|
-
const match = /^(title|status):\s*(.*)$/.exec(line);
|
|
721
|
+
const match = /^(title|status|firmness):\s*(.*)$/.exec(line);
|
|
441
722
|
if (match?.[1] === "title" && match[2])
|
|
442
723
|
summary.title = unquote(match[2]);
|
|
443
724
|
if (match?.[1] === "status" && match[2])
|
|
444
725
|
summary.status = unquote(match[2]);
|
|
726
|
+
// implements REQ-kb-decision-firmness
|
|
727
|
+
if (match?.[1] === "firmness" && match[2])
|
|
728
|
+
summary.firmness = unquote(match[2]);
|
|
445
729
|
if (/^\S/.test(line)) {
|
|
446
730
|
flushLink(authored, pending);
|
|
447
731
|
pending = undefined;
|
package/dist/snippets.d.ts
CHANGED
|
@@ -24,8 +24,16 @@ export declare function focusedSymbols(symbols: readonly IndexedSymbol[], focus:
|
|
|
24
24
|
export declare function editFocus(absolutePath: string, toolInput: unknown): LineRange[] | undefined;
|
|
25
25
|
/** True for a status that marks an entity as no longer current policy. */
|
|
26
26
|
export declare function isRetiredStatus(status: string | undefined): boolean;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* `ID (status) [firmness]: title`, with the status shown only when it is
|
|
29
|
+
* retired and the firmness only when it is firm or exploratory (unlabeled
|
|
30
|
+
* decisions are `default`).
|
|
31
|
+
*/
|
|
28
32
|
export declare function describeEntity(summary: EntitySummary): string;
|
|
33
|
+
/** Discovery note for a source file none of whose symbols implement a requirement. */
|
|
34
|
+
export declare function unownedSourceNote(relativePath: string): string;
|
|
35
|
+
/** Shown instead of the discovery note when the manifest itself could not be read. */
|
|
36
|
+
export declare function unreadableManifestNote(relativePath: string): string;
|
|
29
37
|
/** Link types through which a requirement is grounded in semantic facts. */
|
|
30
38
|
export declare const GROUNDING_LINK_TYPES: readonly string[];
|
|
31
39
|
/**
|
|
@@ -49,6 +57,15 @@ export declare function createEntitySummarizer(workspaceRoot: string): Summarize
|
|
|
49
57
|
* when the manifest links nothing to it.
|
|
50
58
|
*/
|
|
51
59
|
export declare function fileKnowledgeSnippet(input: SnippetInput): string | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Reduced edit snippet for a file whose descriptive snippet (requirements,
|
|
62
|
+
* symbols, tests, next-layer calls) was already shown this session: which
|
|
63
|
+
* symbol the edit lands in, what the lead requirement must keep true and the
|
|
64
|
+
* decision behind it, and the impact check. The descriptive parts are not
|
|
65
|
+
* repeated. Undefined when no symbol of the file implements a requirement
|
|
66
|
+
* and the edit lands in no symbol.
|
|
67
|
+
*/
|
|
68
|
+
export declare function editKnowledgeUpdate(input: SnippetInput): string | undefined;
|
|
52
69
|
/**
|
|
53
70
|
* One-line focus update for a file whose snippet was already shown this
|
|
54
71
|
* session: only the new fact (which symbol the edit lands in) is repeated.
|
|
@@ -69,11 +86,17 @@ export type EditKnowledgeInput = {
|
|
|
69
86
|
/** Most files described in one call (an `apply_patch` may touch many). */
|
|
70
87
|
maxFiles?: number | undefined;
|
|
71
88
|
maxChars?: number | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* Note for a file that gets no snippet (for example an unlinked source
|
|
91
|
+
* file), shown under the same once-per-file-per-session rule.
|
|
92
|
+
*/
|
|
93
|
+
noteFor?: ((relativePath: string) => string | undefined) | undefined;
|
|
72
94
|
};
|
|
73
95
|
/**
|
|
74
96
|
* Pre-edit context for hosts without their own snippet memory: the shared
|
|
75
|
-
* edit snippet for each linked file the call targets
|
|
76
|
-
* session, at most `maxFiles`
|
|
97
|
+
* edit snippet for each linked file the call targets (or `noteFor`'s note for
|
|
98
|
+
* a file without one), shown once per file per session, at most `maxFiles`
|
|
99
|
+
* per call.
|
|
77
100
|
*/
|
|
78
101
|
export declare function editKnowledgeContext(input: EditKnowledgeInput): string | undefined;
|
|
79
102
|
/**
|
package/dist/snippets.js
CHANGED
|
@@ -100,10 +100,18 @@ const RETIRED_STATUS = /supersed|deprecat|reject|obsolete|retired/i;
|
|
|
100
100
|
export function isRetiredStatus(status) {
|
|
101
101
|
return status !== undefined && RETIRED_STATUS.test(status);
|
|
102
102
|
}
|
|
103
|
-
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
104
|
-
/**
|
|
103
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1, REQ-kb-decision-firmness
|
|
104
|
+
/**
|
|
105
|
+
* `ID (status) [firmness]: title`, with the status shown only when it is
|
|
106
|
+
* retired and the firmness only when it is firm or exploratory (unlabeled
|
|
107
|
+
* decisions are `default`).
|
|
108
|
+
*/
|
|
105
109
|
export function describeEntity(summary) {
|
|
106
|
-
const
|
|
110
|
+
const retired = isRetiredStatus(summary.status) ? ` (${summary.status})` : "";
|
|
111
|
+
const firmness = summary.firmness === "firm" || summary.firmness === "exploratory"
|
|
112
|
+
? ` [${summary.firmness}]`
|
|
113
|
+
: "";
|
|
114
|
+
const notable = `${retired}${firmness}`;
|
|
107
115
|
return summary.title
|
|
108
116
|
? `${summary.id}${notable}: ${truncate(summary.title, MAX_TITLE)}`
|
|
109
117
|
: `${summary.id}${notable}`;
|
|
@@ -111,6 +119,26 @@ export function describeEntity(summary) {
|
|
|
111
119
|
function jsonString(value) {
|
|
112
120
|
return JSON.stringify(value);
|
|
113
121
|
}
|
|
122
|
+
// implements REQ-claude-code-kibi-plugin-v1, REQ-codex-kibi-plugin-v1
|
|
123
|
+
/** Discovery note for a source file none of whose symbols implement a requirement. */
|
|
124
|
+
export function unownedSourceNote(relativePath) {
|
|
125
|
+
return [
|
|
126
|
+
`Kibi: no symbol in ${relativePath} is linked to a requirement yet.`,
|
|
127
|
+
`kb_search({query:"<behavior being changed>", sourceLocations:[{path:${jsonString(relativePath)}}]}) surfaces requirements that may already describe it; new behavior is recorded with kb_upsert (requirement + symbol implements link).`,
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
// implements REQ-kibi-hooks-flow-manifest, REQ-claude-code-kibi-plugin-v1, REQ-codex-kibi-plugin-v1
|
|
131
|
+
/** Shown instead of the discovery note when the manifest itself could not be read. */
|
|
132
|
+
export function unreadableManifestNote(relativePath) {
|
|
133
|
+
return [
|
|
134
|
+
"Kibi: the hook could not read .kb/symbols.yaml, so requirement snippets are unavailable this session.",
|
|
135
|
+
`kb_query({sourceFile:${jsonString(relativePath)}}) still lists what ${relativePath} implements; \`kibi sync\` rewrites the manifest in the canonical block layout.`,
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
/** The impact-check line that closes every edit snippet. */
|
|
139
|
+
function impactCheckLine(relativePath) {
|
|
140
|
+
return `Behavior changes here are traced to these requirements; kb_check({sourceFiles:[${jsonString(relativePath)}], includeImpactDiagnostics:true, includeWorkingTreeDiff:true}) reports ownership drift after the edit.`;
|
|
141
|
+
}
|
|
114
142
|
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
115
143
|
/** Link types through which a requirement is grounded in semantic facts. */
|
|
116
144
|
export const GROUNDING_LINK_TYPES = [
|
|
@@ -176,15 +204,11 @@ export function createEntitySummarizer(workspaceRoot) {
|
|
|
176
204
|
return summary;
|
|
177
205
|
};
|
|
178
206
|
}
|
|
179
|
-
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
180
207
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
208
|
+
* Requirement -> symbol titles, with the requirement IDs ranked so the focus
|
|
209
|
+
* symbols' owners lead, then the requirements with the most symbols.
|
|
183
210
|
*/
|
|
184
|
-
|
|
185
|
-
const { relativePath, symbols, surface, summarize } = input;
|
|
186
|
-
const focus = focusedSymbols(symbols, input.focus);
|
|
187
|
-
// Requirement -> symbol titles, focus symbols first so their owners lead.
|
|
211
|
+
function requirementOwners(symbols, focus) {
|
|
188
212
|
const owners = new Map();
|
|
189
213
|
const ordered = [
|
|
190
214
|
...focus,
|
|
@@ -198,6 +222,24 @@ export function fileKnowledgeSnippet(input) {
|
|
|
198
222
|
owners.set(requirement, titles);
|
|
199
223
|
}
|
|
200
224
|
}
|
|
225
|
+
const focusOwners = new Set(focus.flatMap((symbol) => symbol.implements));
|
|
226
|
+
const requirementIds = [...owners.keys()].sort((left, right) => {
|
|
227
|
+
const focusOrder = Number(focusOwners.has(right)) - Number(focusOwners.has(left));
|
|
228
|
+
return focusOrder !== 0
|
|
229
|
+
? focusOrder
|
|
230
|
+
: (owners.get(right)?.length ?? 0) - (owners.get(left)?.length ?? 0);
|
|
231
|
+
});
|
|
232
|
+
return { owners, requirementIds };
|
|
233
|
+
}
|
|
234
|
+
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
235
|
+
/**
|
|
236
|
+
* Knowledge snippet for a file with requirement-linked symbols, or undefined
|
|
237
|
+
* when the manifest links nothing to it.
|
|
238
|
+
*/
|
|
239
|
+
export function fileKnowledgeSnippet(input) {
|
|
240
|
+
const { relativePath, symbols, surface, summarize } = input;
|
|
241
|
+
const focus = focusedSymbols(symbols, input.focus);
|
|
242
|
+
const { owners, requirementIds } = requirementOwners(symbols, focus);
|
|
201
243
|
const tests = [
|
|
202
244
|
...new Set(symbols.flatMap((symbol) => symbol.coveredBy)),
|
|
203
245
|
].sort();
|
|
@@ -207,13 +249,6 @@ export function fileKnowledgeSnippet(input) {
|
|
|
207
249
|
if (owners.size === 0 && tests.length === 0 && executes.length === 0) {
|
|
208
250
|
return undefined;
|
|
209
251
|
}
|
|
210
|
-
const focusOwners = new Set(focus.flatMap((symbol) => symbol.implements));
|
|
211
|
-
const requirementIds = [...owners.keys()].sort((left, right) => {
|
|
212
|
-
const focusOrder = Number(focusOwners.has(right)) - Number(focusOwners.has(left));
|
|
213
|
-
return focusOrder !== 0
|
|
214
|
-
? focusOrder
|
|
215
|
-
: (owners.get(right)?.length ?? 0) - (owners.get(left)?.length ?? 0);
|
|
216
|
-
});
|
|
217
252
|
const lines = [
|
|
218
253
|
`Kibi knowledge for ${relativePath} (symbol manifest):`,
|
|
219
254
|
];
|
|
@@ -252,9 +287,36 @@ export function fileKnowledgeSnippet(input) {
|
|
|
252
287
|
`kb_search({query:"<topic>", sourceLocations:[${location}]}) answers with governing requirements, facts, decisions, and tests`,
|
|
253
288
|
].filter((part) => part !== undefined);
|
|
254
289
|
lines.push(`Next layer: ${next.join("; ")}.`);
|
|
255
|
-
if (surface === "edit")
|
|
256
|
-
lines.push(
|
|
290
|
+
if (surface === "edit")
|
|
291
|
+
lines.push(impactCheckLine(relativePath));
|
|
292
|
+
return truncate(lines.join("\n"), input.maxChars ?? MAX_SNIPPET_CHARS);
|
|
293
|
+
}
|
|
294
|
+
// implements REQ-claude-code-kibi-plugin-v1
|
|
295
|
+
/**
|
|
296
|
+
* Reduced edit snippet for a file whose descriptive snippet (requirements,
|
|
297
|
+
* symbols, tests, next-layer calls) was already shown this session: which
|
|
298
|
+
* symbol the edit lands in, what the lead requirement must keep true and the
|
|
299
|
+
* decision behind it, and the impact check. The descriptive parts are not
|
|
300
|
+
* repeated. Undefined when no symbol of the file implements a requirement
|
|
301
|
+
* and the edit lands in no symbol.
|
|
302
|
+
*/
|
|
303
|
+
export function editKnowledgeUpdate(input) {
|
|
304
|
+
const { relativePath, symbols, summarize } = input;
|
|
305
|
+
const focus = focusedSymbols(symbols, input.focus);
|
|
306
|
+
const { requirementIds } = requirementOwners(symbols, focus);
|
|
307
|
+
const leadId = requirementIds[0];
|
|
308
|
+
const lines = [];
|
|
309
|
+
const focused = focusUpdate(relativePath, symbols, input.focus);
|
|
310
|
+
if (focused) {
|
|
311
|
+
lines.push(focused);
|
|
257
312
|
}
|
|
313
|
+
else if (leadId) {
|
|
314
|
+
lines.push(`Kibi: this edit to ${relativePath} changes code that implements ${formatList(requirementIds, 2)}.`);
|
|
315
|
+
}
|
|
316
|
+
if (!leadId)
|
|
317
|
+
return lines[0];
|
|
318
|
+
lines.push(...requirementGroundingLines(leadId, summarize));
|
|
319
|
+
lines.push(impactCheckLine(relativePath));
|
|
258
320
|
return truncate(lines.join("\n"), input.maxChars ?? MAX_SNIPPET_CHARS);
|
|
259
321
|
}
|
|
260
322
|
// implements REQ-claude-code-kibi-plugin-v1
|
|
@@ -274,8 +336,9 @@ export function focusUpdate(relativePath, symbols, focus) {
|
|
|
274
336
|
// implements REQ-agent-core-edit-snippets, REQ-codex-kibi-plugin-v1, REQ-zcode-kibi-plugin-v1
|
|
275
337
|
/**
|
|
276
338
|
* Pre-edit context for hosts without their own snippet memory: the shared
|
|
277
|
-
* edit snippet for each linked file the call targets
|
|
278
|
-
* session, at most `maxFiles`
|
|
339
|
+
* edit snippet for each linked file the call targets (or `noteFor`'s note for
|
|
340
|
+
* a file without one), shown once per file per session, at most `maxFiles`
|
|
341
|
+
* per call.
|
|
279
342
|
*/
|
|
280
343
|
export function editKnowledgeContext(input) {
|
|
281
344
|
const snippets = [];
|
|
@@ -283,22 +346,23 @@ export function editKnowledgeContext(input) {
|
|
|
283
346
|
if (snippets.length >= (input.maxFiles ?? 3))
|
|
284
347
|
break;
|
|
285
348
|
const symbols = input.symbolsFor(relativePath);
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
349
|
+
const snippet = symbols.length > 0
|
|
350
|
+
? fileKnowledgeSnippet({
|
|
351
|
+
relativePath,
|
|
352
|
+
symbols,
|
|
353
|
+
surface: "edit",
|
|
354
|
+
focus: input.focusFor?.(relativePath),
|
|
355
|
+
summarize: input.summarize,
|
|
356
|
+
maxChars: input.maxChars,
|
|
357
|
+
})
|
|
358
|
+
: undefined;
|
|
359
|
+
const text = snippet ?? input.noteFor?.(relativePath);
|
|
360
|
+
if (!text)
|
|
297
361
|
continue;
|
|
298
362
|
const key = `${input.sessionId ?? ""}\u0000edit\u0000${relativePath}`;
|
|
299
363
|
if (!claimSnippetSlot(input.stateDir, key))
|
|
300
364
|
continue;
|
|
301
|
-
snippets.push(
|
|
365
|
+
snippets.push(text);
|
|
302
366
|
}
|
|
303
367
|
return snippets.length > 0 ? snippets.join("\n\n") : undefined;
|
|
304
368
|
}
|