@stravica-ai/digital-operator-pa 0.1.7 → 0.1.10
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/dist/engineering-pa-cli/src/knowledge/cli.mjs +4 -0
- package/dist/engineering-pa-cli/src/knowledge/graph.mjs +57 -4
- package/dist/engineering-pa-cli/src/knowledge/rebuild.mjs +118 -0
- package/dist/engineering-pa-core/docs/knowledge-scaffold.md +29 -2
- package/dist/engineering-pa-core/src/distill/prompt.mjs +26 -9
- package/dist/engineering-pa-core/src/distill/run.mjs +9 -3
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +102 -93
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +181 -107
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +445 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/write-atomic.mjs +36 -0
- package/dist/engineering-pa-workspace/app/project-detail.html +16 -3
- package/dist/engineering-pa-workspace/app/viewer.html +54 -13
- package/package.json +1 -1
|
@@ -1,186 +1,260 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// graph-builder.mjs — Builds a knowledge graph from forward links and backlinks.
|
|
3
3
|
//
|
|
4
|
-
// Reads forward links from markdown files
|
|
4
|
+
// Reads forward links from markdown files, machine-readable index.json hubs,
|
|
5
|
+
// and backlinks from .backlinks.json.
|
|
5
6
|
// Output: operator/knowledge/.graph.json
|
|
6
7
|
//
|
|
7
8
|
// CLI: node graph-builder.mjs [--root /path/to/repo]
|
|
9
|
+
//
|
|
10
|
+
// Link extraction and resolution live in ./links.mjs, shared with
|
|
11
|
+
// backlink-indexer.mjs — see that file for the resolution contract.
|
|
12
|
+
//
|
|
13
|
+
// LIBRARY + CLI, same split as backlink-indexer.mjs: `buildKnowledgeGraph()` is
|
|
14
|
+
// the importable body, `main()` is the CLI face, and the top-level run is
|
|
15
|
+
// behind a directly-invoked guard.
|
|
8
16
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { realpathSync } from 'node:fs';
|
|
19
|
+
import { join, resolve, dirname, relative } from 'node:path';
|
|
11
20
|
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import {
|
|
22
|
+
buildFileIndex,
|
|
23
|
+
buildIndexHubs,
|
|
24
|
+
deriveDomain,
|
|
25
|
+
extractLinks,
|
|
26
|
+
isIndexJson,
|
|
27
|
+
scanDirsFor,
|
|
28
|
+
toPosix,
|
|
29
|
+
walkFiles,
|
|
30
|
+
} from './links.mjs';
|
|
31
|
+
import { writeJsonAtomic } from './write-atomic.mjs';
|
|
12
32
|
|
|
13
33
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
34
|
const DEFAULT_ROOT = resolve(__dirname, '../../../..');
|
|
15
35
|
|
|
16
36
|
const USAGE = `
|
|
17
|
-
graph-builder — Build a knowledge graph from markdown links and backlinks
|
|
37
|
+
graph-builder — Build a knowledge graph from markdown links, index hubs and backlinks
|
|
18
38
|
|
|
19
39
|
Usage:
|
|
20
40
|
node graph-builder.mjs [options]
|
|
21
41
|
|
|
22
42
|
Options:
|
|
23
43
|
--root <path> Repository root (default: 4 levels up from this script)
|
|
44
|
+
--quiet Suppress the per-link diagnostics report
|
|
24
45
|
--help, -h Show this help
|
|
25
46
|
|
|
26
47
|
Reads forward links from .md files in operator/knowledge/ and core/knowledge/.
|
|
48
|
+
Reads document references from every index.json under those trees.
|
|
27
49
|
Reads backlinks from operator/knowledge/.backlinks.json.
|
|
28
50
|
|
|
29
51
|
Output: operator/knowledge/.graph.json
|
|
30
52
|
`.trim();
|
|
31
53
|
|
|
32
|
-
// ── Helpers ─────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
async function walkDir(dir) {
|
|
35
|
-
const results = [];
|
|
36
|
-
let entries;
|
|
37
|
-
try {
|
|
38
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
39
|
-
} catch (err) {
|
|
40
|
-
if (err.code === 'ENOENT') return results;
|
|
41
|
-
throw err;
|
|
42
|
-
}
|
|
43
|
-
for (const entry of entries) {
|
|
44
|
-
const full = join(dir, entry.name);
|
|
45
|
-
if (entry.isDirectory()) {
|
|
46
|
-
if (entry.name.startsWith('.')) continue;
|
|
47
|
-
results.push(...await walkDir(full));
|
|
48
|
-
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
49
|
-
results.push(full);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return results;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
54
|
function extractTitle(content) {
|
|
56
55
|
const match = content.match(/^#\s+(.+)$/m);
|
|
57
56
|
return match ? match[1].trim() : null;
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
const sourceDir = dirname(sourcePath);
|
|
63
|
-
|
|
64
|
-
// Wiki-style: [[target]]
|
|
65
|
-
const wikiRe = /\[\[([^\]]+)\]\]/g;
|
|
66
|
-
let m;
|
|
67
|
-
while ((m = wikiRe.exec(content)) !== null) {
|
|
68
|
-
let target = m[1].trim();
|
|
69
|
-
if (!target.endsWith('.md')) target += '.md';
|
|
70
|
-
links.push(normalize(target));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Markdown-style: [text](relative/path.md)
|
|
74
|
-
const mdRe = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
75
|
-
while ((m = mdRe.exec(content)) !== null) {
|
|
76
|
-
const href = m[2].trim();
|
|
77
|
-
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('#') || href.startsWith('mailto:')) continue;
|
|
78
|
-
const path = href.split('#')[0];
|
|
79
|
-
if (!path || !path.endsWith('.md')) continue;
|
|
80
|
-
const abs = resolve(sourceDir, path);
|
|
81
|
-
const rel = relative(repoRoot, abs);
|
|
82
|
-
links.push(normalize(rel));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return [...new Set(links)];
|
|
59
|
+
function stubNode(id) {
|
|
60
|
+
return { id, title: id.split('/').pop().replace('.md', ''), domain: deriveDomain(id) };
|
|
86
61
|
}
|
|
87
62
|
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
63
|
+
function reportUnresolved(label, entries) {
|
|
64
|
+
if (entries.length === 0) return;
|
|
65
|
+
const byReason = new Map();
|
|
66
|
+
for (const e of entries) byReason.set(e.reason, (byReason.get(e.reason) ?? 0) + 1);
|
|
67
|
+
const summary = [...byReason.entries()].map(([r, n]) => `${r}=${n}`).join(' ');
|
|
68
|
+
console.warn(`${label}: ${entries.length} unresolved (${summary})`);
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
const extra = e.candidates ? ` [candidates: ${e.candidates.join(', ')}]` : '';
|
|
71
|
+
console.warn(` ${e.reason}: ${e.source} -> ${e.target}${extra}`);
|
|
92
72
|
}
|
|
93
|
-
return null;
|
|
94
73
|
}
|
|
95
74
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const scanDirs =
|
|
113
|
-
join(root, 'operator', 'knowledge'),
|
|
114
|
-
join(root, 'core', 'knowledge'),
|
|
115
|
-
];
|
|
75
|
+
/**
|
|
76
|
+
* Build the knowledge graph over `root` and write
|
|
77
|
+
* `operator/knowledge/.graph.json`.
|
|
78
|
+
*
|
|
79
|
+
* Depends on `.backlinks.json` already being current -- run
|
|
80
|
+
* `buildBacklinkIndex()` first. Building the graph over a stale backlinks file
|
|
81
|
+
* silently produces a graph whose backlink edges describe the PREVIOUS tree.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} args
|
|
84
|
+
* @param {string} args.root workspace root the knowledge tree hangs off
|
|
85
|
+
* @param {object|null} [args.coverage] optional provenance stamp merged into
|
|
86
|
+
* the artefact as `coverage` (see backlink-indexer.mjs).
|
|
87
|
+
* @returns {Promise<object>} outPath, counts, diagnostics and the unresolved
|
|
88
|
+
* entries the CLI reports
|
|
89
|
+
*/
|
|
90
|
+
export async function buildKnowledgeGraph({ root, coverage = null }) {
|
|
91
|
+
const scanDirs = scanDirsFor(root);
|
|
116
92
|
|
|
93
|
+
// ── Pass 1: enumerate the tree ────────────────────────────────────────
|
|
94
|
+
// Resolution needs the whole file set before any link is interpreted —
|
|
95
|
+
// `[[slug]]` cannot be resolved without knowing which files exist.
|
|
117
96
|
const allFiles = [];
|
|
97
|
+
const indexPaths = [];
|
|
118
98
|
for (const dir of scanDirs) {
|
|
119
|
-
allFiles.push(...await
|
|
99
|
+
allFiles.push(...await walkFiles(dir));
|
|
100
|
+
indexPaths.push(...await walkFiles(dir, isIndexJson));
|
|
120
101
|
}
|
|
102
|
+
const fileIds = allFiles.map((f) => toPosix(relative(root, f)));
|
|
103
|
+
const fileIndex = buildFileIndex(fileIds);
|
|
121
104
|
|
|
105
|
+
// ── Pass 2: nodes + forward edges ─────────────────────────────────────
|
|
122
106
|
const nodesMap = new Map();
|
|
123
107
|
const edges = [];
|
|
124
108
|
const edgeSet = new Set();
|
|
109
|
+
const unresolvedLinks = [];
|
|
110
|
+
|
|
111
|
+
const addEdge = (from, to, type) => {
|
|
112
|
+
const edgeKey = `${type}:${from}->${to}`;
|
|
113
|
+
if (edgeSet.has(edgeKey)) return;
|
|
114
|
+
edgeSet.add(edgeKey);
|
|
115
|
+
edges.push({ from, to, type });
|
|
116
|
+
};
|
|
125
117
|
|
|
126
|
-
for (
|
|
118
|
+
for (let i = 0; i < allFiles.length; i++) {
|
|
119
|
+
const filePath = allFiles[i];
|
|
120
|
+
const relPath = fileIds[i];
|
|
127
121
|
const content = await readFile(filePath, 'utf8');
|
|
128
|
-
const relPath = relative(root, filePath);
|
|
129
122
|
const title = extractTitle(content) || relPath.split('/').pop().replace('.md', '');
|
|
130
|
-
const domain = deriveDomain(relPath);
|
|
131
123
|
|
|
132
|
-
nodesMap.set(relPath, { id: relPath, title, domain });
|
|
124
|
+
nodesMap.set(relPath, { id: relPath, title, domain: deriveDomain(relPath) });
|
|
133
125
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
126
|
+
const { links, unresolved } = extractLinks(content, relPath, fileIndex);
|
|
127
|
+
unresolvedLinks.push(...unresolved);
|
|
128
|
+
for (const target of links) addEdge(relPath, target, 'link');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── index.json hubs ───────────────────────────────────────────────────
|
|
132
|
+
const indexFiles = [];
|
|
133
|
+
for (const indexPath of indexPaths) {
|
|
134
|
+
const id = toPosix(relative(root, indexPath));
|
|
135
|
+
let data;
|
|
136
|
+
try {
|
|
137
|
+
data = JSON.parse(await readFile(indexPath, 'utf8'));
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn(`Skipping unparseable index: ${id} (${err.message})`);
|
|
140
|
+
continue;
|
|
141
141
|
}
|
|
142
|
+
indexFiles.push({ id, data });
|
|
142
143
|
}
|
|
144
|
+
const hubs = buildIndexHubs(indexFiles, fileIndex);
|
|
145
|
+
for (const node of hubs.nodes) nodesMap.set(node.id, node);
|
|
146
|
+
for (const edge of hubs.edges) addEdge(edge.from, edge.to, edge.type);
|
|
143
147
|
|
|
144
|
-
//
|
|
148
|
+
// ── Backlink edges (reversed: target -> source) ────────────────────────
|
|
145
149
|
const backlinksPath = join(root, 'operator', 'knowledge', '.backlinks.json');
|
|
150
|
+
let backlinksMissing = false;
|
|
146
151
|
try {
|
|
147
152
|
const raw = await readFile(backlinksPath, 'utf8');
|
|
148
153
|
const backlinksData = JSON.parse(raw);
|
|
149
154
|
const bl = backlinksData.backlinks || {};
|
|
150
155
|
for (const [target, sources] of Object.entries(bl)) {
|
|
151
156
|
for (const source of sources) {
|
|
152
|
-
|
|
153
|
-
if (!
|
|
154
|
-
|
|
155
|
-
edges.push({ from: target, to: source, type: 'backlink' });
|
|
156
|
-
}
|
|
157
|
-
if (!nodesMap.has(target)) {
|
|
158
|
-
nodesMap.set(target, { id: target, title: target.split('/').pop().replace('.md', ''), domain: deriveDomain(target) });
|
|
159
|
-
}
|
|
160
|
-
if (!nodesMap.has(source)) {
|
|
161
|
-
nodesMap.set(source, { id: source, title: source.split('/').pop().replace('.md', ''), domain: deriveDomain(source) });
|
|
162
|
-
}
|
|
157
|
+
addEdge(target, source, 'backlink');
|
|
158
|
+
if (!nodesMap.has(target)) nodesMap.set(target, stubNode(target));
|
|
159
|
+
if (!nodesMap.has(source)) nodesMap.set(source, stubNode(source));
|
|
163
160
|
}
|
|
164
161
|
}
|
|
165
162
|
} catch (err) {
|
|
166
163
|
if (err.code !== 'ENOENT') throw err;
|
|
167
|
-
|
|
164
|
+
backlinksMissing = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Markdown links to files that do not exist on this host still become stub
|
|
168
|
+
// nodes — that is how a doc present in one entitlement tier and absent from
|
|
169
|
+
// the build host stays visible. Wiki links do not: an unresolved `[[slug]]`
|
|
170
|
+
// is a dangling reference, not a missing file.
|
|
171
|
+
for (const edge of edges) {
|
|
172
|
+
if (edge.type !== 'link') continue;
|
|
173
|
+
if (!nodesMap.has(edge.to)) nodesMap.set(edge.to, stubNode(edge.to));
|
|
168
174
|
}
|
|
169
175
|
|
|
170
176
|
const nodes = [...nodesMap.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
177
|
+
edges.sort((a, b) => a.type.localeCompare(b.type) || a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
171
178
|
|
|
172
179
|
const output = {
|
|
173
180
|
version: '1.0',
|
|
174
181
|
generatedAt: new Date().toISOString(),
|
|
182
|
+
diagnostics: {
|
|
183
|
+
markdownFiles: allFiles.length,
|
|
184
|
+
indexHubs: {
|
|
185
|
+
files: indexFiles.length,
|
|
186
|
+
entries: hubs.entryCount,
|
|
187
|
+
resolved: hubs.resolvedCount,
|
|
188
|
+
unresolved: hubs.unresolved.length,
|
|
189
|
+
},
|
|
190
|
+
wikiLinks: {
|
|
191
|
+
unresolved: unresolvedLinks.length,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
175
194
|
nodes,
|
|
176
195
|
edges,
|
|
177
196
|
};
|
|
197
|
+
if (coverage) output.coverage = coverage;
|
|
178
198
|
|
|
179
199
|
const outPath = join(root, 'operator', 'knowledge', '.graph.json');
|
|
180
|
-
await
|
|
181
|
-
|
|
200
|
+
await writeJsonAtomic(outPath, output);
|
|
201
|
+
return {
|
|
202
|
+
outPath,
|
|
203
|
+
nodeCount: nodes.length,
|
|
204
|
+
edgeCount: edges.length,
|
|
205
|
+
backlinksMissing,
|
|
206
|
+
indexFileCount: indexFiles.length,
|
|
207
|
+
hubEntryCount: hubs.entryCount,
|
|
208
|
+
hubResolvedCount: hubs.resolvedCount,
|
|
209
|
+
unresolvedLinks,
|
|
210
|
+
unresolvedIndexEntries: hubs.unresolved,
|
|
211
|
+
diagnostics: output.diagnostics,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function main(args) {
|
|
216
|
+
let root = DEFAULT_ROOT;
|
|
217
|
+
let quiet = false;
|
|
218
|
+
|
|
219
|
+
for (let i = 0; i < args.length; i++) {
|
|
220
|
+
const a = args[i];
|
|
221
|
+
if (a === '--help' || a === '-h') {
|
|
222
|
+
console.log(USAGE);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
if (a === '--quiet') quiet = true;
|
|
226
|
+
if (a === '--root') {
|
|
227
|
+
root = resolve(args[++i]);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const result = await buildKnowledgeGraph({ root });
|
|
232
|
+
|
|
233
|
+
if (result.backlinksMissing) {
|
|
234
|
+
console.warn('No .backlinks.json found — run backlink-indexer.mjs first. Building graph from forward links only.');
|
|
235
|
+
}
|
|
236
|
+
console.log(`Wrote ${result.outPath} (${result.nodeCount} nodes, ${result.edgeCount} edges)`);
|
|
237
|
+
console.log(
|
|
238
|
+
`Index hubs: ${result.indexFileCount} files, ${result.hubResolvedCount}/${result.hubEntryCount} entries resolved`,
|
|
239
|
+
);
|
|
240
|
+
if (!quiet) {
|
|
241
|
+
reportUnresolved('Wiki links', result.unresolvedLinks);
|
|
242
|
+
reportUnresolved('Index entries', result.unresolvedIndexEntries);
|
|
243
|
+
}
|
|
182
244
|
return 0;
|
|
183
245
|
}
|
|
184
246
|
|
|
185
|
-
|
|
186
|
-
|
|
247
|
+
// See backlink-indexer.mjs for why both sides are realpath'd.
|
|
248
|
+
function isDirectlyInvoked() {
|
|
249
|
+
if (!process.argv[1]) return false;
|
|
250
|
+
try {
|
|
251
|
+
return fileURLToPath(import.meta.url) === realpathSync(resolve(process.argv[1]));
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (isDirectlyInvoked()) {
|
|
258
|
+
const exitCode = await main(process.argv.slice(2));
|
|
259
|
+
process.exit(exitCode ?? 0);
|
|
260
|
+
}
|