total-recall-brain 3.6.13 → 3.7.0
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/bin/total-recall.mjs +2 -0
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +32 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +3 -0
- package/src/cli/export.mjs +85 -0
- package/src/cli/ingest-okf.mjs +132 -0
- package/src/cli/ingest.mjs +9 -0
- package/src/cli/lint.mjs +39 -3
- package/src/core/okf-adapter.mjs +464 -0
- package/src/core/schema.mjs +2 -0
- package/src/core/surface.mjs +20 -3
package/bin/total-recall.mjs
CHANGED
|
@@ -80,6 +80,7 @@ const COMMANDS = {
|
|
|
80
80
|
help: 'help.mjs',
|
|
81
81
|
collab: 'collab.mjs',
|
|
82
82
|
command: 'command.mjs',
|
|
83
|
+
export: 'export.mjs',
|
|
83
84
|
};
|
|
84
85
|
function printHelp() {
|
|
85
86
|
console.log(`
|
|
@@ -122,6 +123,7 @@ function printHelp() {
|
|
|
122
123
|
into the vault (run before compile on first install)
|
|
123
124
|
ingest [--watch] Ingest IDE conversation logs (Claude Code, Codex, Gemini, etc.)
|
|
124
125
|
ingest google-takeout <path> Ingest Google Takeout export data
|
|
126
|
+
export <path> --okf Export memory vault as an OKF-compliant bundle
|
|
125
127
|
connect <client> Configure Cursor, Claude Code, Codex, UltraChat, Gemini, etc.
|
|
126
128
|
sync [--watch] Pull remote brain instructions into the current workspace
|
|
127
129
|
relay <cmd> Local background relay: ship IDE sessions to remote brain
|
package/package.json
CHANGED
|
@@ -145,6 +145,38 @@ npx total-recall compile
|
|
|
145
145
|
```
|
|
146
146
|
Synchronously rebuild the entire memory index (derived embeddings) and regenerate prompt instruction shims (such as `INSTRUCTIONS.md`, `GEMINI.md`, `AGENTS.md`, etc.).
|
|
147
147
|
|
|
148
|
+
### 5. Ingest OKF Bundle
|
|
149
|
+
```bash
|
|
150
|
+
npx total-recall ingest okf <path> [options]
|
|
151
|
+
```
|
|
152
|
+
Recursively parse and ingest an Open Knowledge Format (OKF v0.1 Draft) bundle directory.
|
|
153
|
+
* **Options**:
|
|
154
|
+
* `--dry-run`: Parse and validate nodes without writing files.
|
|
155
|
+
* `--category <name>`: Override and force all imported nodes to a specific category.
|
|
156
|
+
* `--importance <1-5>`: Override importance for imported nodes.
|
|
157
|
+
* `--on-conflict <strategy>`: Slug conflict behavior (`overwrite`, `skip`, `warn` - default is `warn`).
|
|
158
|
+
* `--type-map <json>`: Custom type-to-category mapping JSON.
|
|
159
|
+
* *Note*: Spawns an asynchronous background surface compile process immediately on successful ingest.
|
|
160
|
+
|
|
161
|
+
### 6. Export OKF Bundle
|
|
162
|
+
```bash
|
|
163
|
+
npx total-recall export <path> [options]
|
|
164
|
+
```
|
|
165
|
+
Export the memory vault into an OKF-compliant directory structure.
|
|
166
|
+
* **Options**:
|
|
167
|
+
* `--okf`: Run in OKF export mode.
|
|
168
|
+
* `--strip-ssss`: Strip all SSSS-specific metadata parameters from output frontmatter.
|
|
169
|
+
* `--format <type>`: Package output as archive (`tar.gz`).
|
|
170
|
+
* `--global` / `--project`: Target scope layer.
|
|
171
|
+
|
|
172
|
+
### 7. Lint OKF Compliance
|
|
173
|
+
```bash
|
|
174
|
+
npx total-recall lint --okf [options]
|
|
175
|
+
```
|
|
176
|
+
Scan vault memory nodes and check for OKF metadata compliance (presence of title, description, tags, and updated timestamp).
|
|
177
|
+
* **Options**:
|
|
178
|
+
* `--strict`: Promote compliance warnings to errors and exit with code 1.
|
|
179
|
+
|
|
148
180
|
---
|
|
149
181
|
|
|
150
182
|
## 🌐 DIRECT REST API REFERENCE
|
|
@@ -127,6 +127,9 @@ SSSS schema v2. Required schema v2 knowledge-graph fields are:
|
|
|
127
127
|
|
|
128
128
|
Invariants additionally use `priority: absolute` and `immutable: true`.
|
|
129
129
|
|
|
130
|
+
### OKF Compatibility Note
|
|
131
|
+
SSSS v0.2 is a strict superset of the Open Knowledge Format (OKF v0.1 Draft). SSSS memory nodes are fully OKF-compatible, as they are Markdown files containing a YAML frontmatter block with a `type` field (which maps to SSSS `category` via the OKF adapter). To bridge the format seamlessly, SSSS v0.2 extends `MemoryNodeSchema` with two optional OKF-standard fields: `description` and `resource` (supporting non-HTTP URIs such as `gs://` or `s3://`).
|
|
132
|
+
|
|
130
133
|
Memory validation is owned by `TotalRecallMemoryValidator`, not by generic loose
|
|
131
134
|
frontmatter checks. No memory path may be committed with zero validation.
|
|
132
135
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseLayerFlag, resolveBrainDir } from './agent-dir.mjs';
|
|
4
|
+
import { exportBundle } from '../core/okf-adapter.mjs';
|
|
5
|
+
|
|
6
|
+
export default async function exportCommand(args) {
|
|
7
|
+
const { layer, remainingArgs } = parseLayerFlag(args);
|
|
8
|
+
|
|
9
|
+
const opts = {
|
|
10
|
+
outputPath: null,
|
|
11
|
+
okf: false,
|
|
12
|
+
format: 'dir',
|
|
13
|
+
stripSsss: false,
|
|
14
|
+
help: false
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < remainingArgs.length; i++) {
|
|
18
|
+
const arg = remainingArgs[i];
|
|
19
|
+
const val = remainingArgs[i + 1];
|
|
20
|
+
|
|
21
|
+
switch (arg) {
|
|
22
|
+
case '--okf':
|
|
23
|
+
opts.okf = true;
|
|
24
|
+
break;
|
|
25
|
+
case '--format':
|
|
26
|
+
if (val) { opts.format = val; i++; }
|
|
27
|
+
break;
|
|
28
|
+
case '--strip-ssss':
|
|
29
|
+
opts.stripSsss = true;
|
|
30
|
+
break;
|
|
31
|
+
case '--help': case '-h':
|
|
32
|
+
opts.help = true;
|
|
33
|
+
break;
|
|
34
|
+
default:
|
|
35
|
+
if (!arg.startsWith('-') && !opts.outputPath) {
|
|
36
|
+
opts.outputPath = arg;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (opts.help || !opts.outputPath || !opts.okf) {
|
|
42
|
+
printExportHelp();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const brainDir = resolveBrainDir(layer);
|
|
47
|
+
const vaultDir = path.join(brainDir, 'memory-vault');
|
|
48
|
+
|
|
49
|
+
console.log(`\n 📤 Exporting Memory Vault to OKF Bundle...`);
|
|
50
|
+
console.log(` 🧠 Source Brain: ${layer} (${vaultDir})`);
|
|
51
|
+
console.log(` 📂 Destination: ${opts.outputPath} (Format: ${opts.format})`);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const report = await exportBundle(vaultDir, opts.outputPath, {
|
|
55
|
+
format: opts.format,
|
|
56
|
+
stripSsss: opts.stripSsss
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
console.log(`\n Export Summary:`);
|
|
60
|
+
console.log(` 🟢 Exported: ${report.exported.length} concepts`);
|
|
61
|
+
console.log(` 📄 generated index.md`);
|
|
62
|
+
console.log(` 📄 generated log.md`);
|
|
63
|
+
console.log(` ✅ Export completed successfully.\n`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(`\n ❌ Export failed: ${err.message}\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printExportHelp() {
|
|
71
|
+
console.log(`
|
|
72
|
+
total-recall export — Export Total Recall knowledge bases
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
total-recall export <output-path> --okf [options]
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--okf Export as an Open Knowledge Format (OKF) bundle (required)
|
|
79
|
+
--format <dir|tar.gz> Output format: directory or compressed tarball (default: dir)
|
|
80
|
+
--strip-ssss Remove all SSSS-specific metadata fields, leaving only pure OKF
|
|
81
|
+
--global Source from the global brain
|
|
82
|
+
--project Source from the project brain (default)
|
|
83
|
+
--help, -h Show this help
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseLayerFlag, resolveBrainDir } from './agent-dir.mjs';
|
|
4
|
+
import { importBundle } from '../core/okf-adapter.mjs';
|
|
5
|
+
|
|
6
|
+
export async function runOkfIngest(args) {
|
|
7
|
+
const { layer, remainingArgs } = parseLayerFlag(args);
|
|
8
|
+
|
|
9
|
+
const opts = {
|
|
10
|
+
bundlePath: null,
|
|
11
|
+
dryRun: false,
|
|
12
|
+
category: null,
|
|
13
|
+
importance: null,
|
|
14
|
+
onConflict: 'warn',
|
|
15
|
+
typeMap: null,
|
|
16
|
+
help: false
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < remainingArgs.length; i++) {
|
|
20
|
+
const arg = remainingArgs[i];
|
|
21
|
+
const val = remainingArgs[i + 1];
|
|
22
|
+
|
|
23
|
+
switch (arg) {
|
|
24
|
+
case '--dry-run':
|
|
25
|
+
opts.dryRun = true;
|
|
26
|
+
break;
|
|
27
|
+
case '--category':
|
|
28
|
+
if (val) { opts.category = val; i++; }
|
|
29
|
+
break;
|
|
30
|
+
case '--importance':
|
|
31
|
+
if (val) { opts.importance = parseInt(val, 10); i++; }
|
|
32
|
+
break;
|
|
33
|
+
case '--on-conflict':
|
|
34
|
+
if (val) { opts.onConflict = val; i++; }
|
|
35
|
+
break;
|
|
36
|
+
case '--type-map':
|
|
37
|
+
if (val) {
|
|
38
|
+
const map = {};
|
|
39
|
+
const pairs = val.split(',');
|
|
40
|
+
for (const pair of pairs) {
|
|
41
|
+
const parts = pair.split('=');
|
|
42
|
+
if (parts.length === 2) {
|
|
43
|
+
map[parts[0].trim()] = parts[1].trim();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
opts.typeMap = map;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
case '--help': case '-h':
|
|
51
|
+
opts.help = true;
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
if (!arg.startsWith('-') && !opts.bundlePath) {
|
|
55
|
+
opts.bundlePath = arg;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (opts.help || !opts.bundlePath) {
|
|
61
|
+
printIngestOkfHelp();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const brainDir = resolveBrainDir(layer);
|
|
66
|
+
const vaultDir = path.join(brainDir, 'memory-vault');
|
|
67
|
+
|
|
68
|
+
console.log(`\n 📥 Importing OKF Bundle from: ${opts.bundlePath}`);
|
|
69
|
+
console.log(` 🧠 Target Brain: ${layer} (${vaultDir})`);
|
|
70
|
+
if (opts.dryRun) console.log(' ⚠️ DRY RUN: No actual writes will be committed.');
|
|
71
|
+
|
|
72
|
+
const report = await importBundle(opts.bundlePath, vaultDir, {
|
|
73
|
+
dryRun: opts.dryRun,
|
|
74
|
+
category: opts.category,
|
|
75
|
+
importance: opts.importance,
|
|
76
|
+
onConflict: opts.onConflict,
|
|
77
|
+
typeMap: opts.typeMap
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
console.log(`\n Import Summary:`);
|
|
81
|
+
console.log(` 🟢 Imported: ${report.imported.length} concepts`);
|
|
82
|
+
console.log(` 🟡 Skipped: ${report.skipped.length} concepts`);
|
|
83
|
+
console.log(` 🔴 Errors: ${report.errors.length} concepts`);
|
|
84
|
+
|
|
85
|
+
if (report.skipped.length > 0) {
|
|
86
|
+
console.log(`\n Skipped detail:`);
|
|
87
|
+
for (const item of report.skipped) {
|
|
88
|
+
console.log(` • ${item.file}: ${item.reason}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (report.errors.length > 0) {
|
|
93
|
+
console.log(`\n Errors detail:`);
|
|
94
|
+
for (const item of report.errors) {
|
|
95
|
+
console.log(` • ${item.file}: ${item.error}`);
|
|
96
|
+
}
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (report.imported.length > 0 && !opts.dryRun) {
|
|
101
|
+
// Recompile active memory surfaces and indexes in the background to avoid blocking the CLI call.
|
|
102
|
+
console.log('\n ⏳ Recompiling active memory surfaces and indexes in the background...');
|
|
103
|
+
try {
|
|
104
|
+
const { spawn } = await import('node:child_process');
|
|
105
|
+
const child = spawn(process.argv[0], [process.argv[1], 'compile'], {
|
|
106
|
+
detached: true,
|
|
107
|
+
stdio: 'ignore'
|
|
108
|
+
});
|
|
109
|
+
child.unref();
|
|
110
|
+
console.log(' ✅ Background compilation started.');
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.warn(` ⚠️ Background recompilation spawn failed: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function printIngestOkfHelp() {
|
|
118
|
+
console.log(`
|
|
119
|
+
Usage:
|
|
120
|
+
total-recall ingest okf <bundle-path> [options]
|
|
121
|
+
|
|
122
|
+
Options:
|
|
123
|
+
--dry-run Validate concepts and preview import without writing
|
|
124
|
+
--category <name> Override and force category for all imported concepts
|
|
125
|
+
--importance <1-5> Override default importance (default: 3)
|
|
126
|
+
--on-conflict <strategy> Strategy for duplicate slugs: 'skip' | 'warn' | 'overwrite' (default: warn)
|
|
127
|
+
--type-map <mapping> Custom type mappings. Format: "Type A=facts,Type B=concepts"
|
|
128
|
+
--global Target the global brain
|
|
129
|
+
--project Target the project brain (default)
|
|
130
|
+
--help, -h Show this help
|
|
131
|
+
`);
|
|
132
|
+
}
|
package/src/cli/ingest.mjs
CHANGED
|
@@ -38,6 +38,8 @@ function printHelp() {
|
|
|
38
38
|
Subcommands:
|
|
39
39
|
google-takeout <path> Ingest Google Takeout export data
|
|
40
40
|
Use 'total-recall ingest google-takeout --help' for details
|
|
41
|
+
okf <path> Ingest an Open Knowledge Format (OKF) bundle directory
|
|
42
|
+
Use 'total-recall ingest okf --help' for details
|
|
41
43
|
|
|
42
44
|
IDE Session Ingest:
|
|
43
45
|
Scans known local directories for conversation history from:
|
|
@@ -52,6 +54,7 @@ function printHelp() {
|
|
|
52
54
|
Usage:
|
|
53
55
|
total-recall ingest [options]
|
|
54
56
|
total-recall ingest google-takeout <path> [options]
|
|
57
|
+
total-recall ingest okf <path> [options]
|
|
55
58
|
|
|
56
59
|
Options:
|
|
57
60
|
--sources <list> Comma-separated source filter (e.g. claude-code,codex)
|
|
@@ -68,6 +71,12 @@ export default async function ingest(args) {
|
|
|
68
71
|
return;
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
if (args[0] === 'okf') {
|
|
75
|
+
const { runOkfIngest } = await import('./ingest-okf.mjs');
|
|
76
|
+
await runOkfIngest(args.slice(1));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
71
80
|
const opts = parseArgs(args);
|
|
72
81
|
if (opts.help) { printHelp(); return; }
|
|
73
82
|
|
package/src/cli/lint.mjs
CHANGED
|
@@ -24,12 +24,13 @@ const AGENT_DIR = resolveAgentDir();
|
|
|
24
24
|
const BRAIN_DIR = resolveBrainDir();
|
|
25
25
|
|
|
26
26
|
function parseArgs(args) {
|
|
27
|
-
const opts = { vault: null, strict: false, json: false, help: false };
|
|
27
|
+
const opts = { vault: null, strict: false, json: false, help: false, okf: false };
|
|
28
28
|
for (let i = 0; i < args.length; i++) {
|
|
29
29
|
switch (args[i]) {
|
|
30
30
|
case '--vault': opts.vault = args[++i]; break;
|
|
31
31
|
case '--strict': opts.strict = true; break;
|
|
32
32
|
case '--json': opts.json = true; break;
|
|
33
|
+
case '--okf': opts.okf = true; break;
|
|
33
34
|
case '--help': case '-h': opts.help = true; break;
|
|
34
35
|
}
|
|
35
36
|
}
|
|
@@ -38,16 +39,17 @@ function parseArgs(args) {
|
|
|
38
39
|
|
|
39
40
|
function printHelp() {
|
|
40
41
|
console.log(`
|
|
41
|
-
total-recall lint — Validate vault nodes against SSSS schema v2
|
|
42
|
+
total-recall lint — Validate vault nodes against SSSS schema v2 or OKF spec
|
|
42
43
|
|
|
43
44
|
Scans all .md files in the memory vault and validates their YAML
|
|
44
|
-
frontmatter against the Zod schemas
|
|
45
|
+
frontmatter against the Zod schemas or checks for OKF compliance.
|
|
45
46
|
|
|
46
47
|
Usage: total-recall lint [options]
|
|
47
48
|
|
|
48
49
|
Options:
|
|
49
50
|
--vault <path> Override vault directory (default: ~/.agent/memory-vault)
|
|
50
51
|
--strict Treat warnings as errors (exit 1 on any issue)
|
|
52
|
+
--okf Verify OKF v0.1 draft metadata compliance
|
|
51
53
|
--json Output results as JSONL
|
|
52
54
|
--help, -h Show this help
|
|
53
55
|
|
|
@@ -63,6 +65,40 @@ export default async function lint(args) {
|
|
|
63
65
|
|
|
64
66
|
const vaultDir = opts.vault || path.join(BRAIN_DIR, 'memory-vault');
|
|
65
67
|
|
|
68
|
+
if (opts.okf) {
|
|
69
|
+
const { lintOkfCompliance } = await import('../core/okf-adapter.mjs');
|
|
70
|
+
const report = lintOkfCompliance(vaultDir, { strict: opts.strict });
|
|
71
|
+
|
|
72
|
+
if (opts.json) {
|
|
73
|
+
console.log(JSON.stringify(report));
|
|
74
|
+
} else {
|
|
75
|
+
console.error(`\n 🔍 OKF Compliance Report for: ${vaultDir}\n`);
|
|
76
|
+
for (const warn of report.warnings) {
|
|
77
|
+
console.error(` ⚠️ ${warn.file}: ${warn.message}`);
|
|
78
|
+
}
|
|
79
|
+
for (const err of report.errors) {
|
|
80
|
+
console.error(` ❌ ${err.file}: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.error(`\n ── Results ──`);
|
|
84
|
+
console.error(` Total scanned: ${report.total}`);
|
|
85
|
+
console.error(` Pass: ${report.pass ? 'Yes' : 'No'}`);
|
|
86
|
+
console.error(` Warnings: ${report.warnings.length}`);
|
|
87
|
+
console.error(` Errors: ${report.errors.length}`);
|
|
88
|
+
|
|
89
|
+
if (report.pass) {
|
|
90
|
+
console.error(`\n ✅ OKF Compliance passed\n`);
|
|
91
|
+
} else {
|
|
92
|
+
console.error(`\n ❌ OKF Compliance failed\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!report.pass) {
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
if (!opts.json) {
|
|
67
103
|
console.error(`\n 🔍 Linting vault: ${vaultDir}\n`);
|
|
68
104
|
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { safeStringify } from './vault.mjs';
|
|
6
|
+
import { writeNodeValidated } from './validated-write.mjs';
|
|
7
|
+
import { getNodes } from './vault-cache.mjs';
|
|
8
|
+
import { validateMemoryNode } from './total-recall-memory-validator.mjs';
|
|
9
|
+
|
|
10
|
+
// ─── DEFAULT OKF TYPE MAP ──────────────────────────────────────────────────
|
|
11
|
+
export const DEFAULT_OKF_TYPE_MAP = {
|
|
12
|
+
'BigQuery Table': 'facts',
|
|
13
|
+
'BigQuery Dataset': 'facts',
|
|
14
|
+
'API Endpoint': 'facts',
|
|
15
|
+
'Metric': 'concepts',
|
|
16
|
+
'Playbook': 'patterns',
|
|
17
|
+
'Runbook': 'patterns',
|
|
18
|
+
'Reference': 'facts',
|
|
19
|
+
'Architecture': 'concepts',
|
|
20
|
+
'Decision': 'decisions',
|
|
21
|
+
'Policy': 'invariants',
|
|
22
|
+
'Best Practice': 'patterns',
|
|
23
|
+
'Anti-Pattern': 'anti-patterns',
|
|
24
|
+
'*': 'facts',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ─── Pure Mapping Functions ────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Derives a valid SSSS subject from a title.
|
|
31
|
+
*/
|
|
32
|
+
function deriveSubject(title) {
|
|
33
|
+
let subject = 'okf.concept';
|
|
34
|
+
if (title) {
|
|
35
|
+
const cleaned = title.replace(/[^a-zA-Z0-9_\s.-]/g, '').trim();
|
|
36
|
+
if (cleaned) {
|
|
37
|
+
const words = cleaned.split(/\s+/);
|
|
38
|
+
if (words.length > 0 && words[0]) {
|
|
39
|
+
subject = words[0].toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return /^[a-zA-Z0-9_\s.-]+$/.test(subject) ? subject : 'okf.concept';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Derives a description from a Markdown body if absent.
|
|
48
|
+
*/
|
|
49
|
+
function deriveDescription(body) {
|
|
50
|
+
if (!body) return undefined;
|
|
51
|
+
const match = body.trim().match(/^[^.!?\n]+[.!?]/);
|
|
52
|
+
return match ? match[0].trim() : body.trim().split('\n')[0].trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Capitalizes SSSS category to OKF type.
|
|
57
|
+
*/
|
|
58
|
+
function categoryToOkfType(category) {
|
|
59
|
+
const reverseMap = {
|
|
60
|
+
'facts': 'Fact',
|
|
61
|
+
'concepts': 'Concept',
|
|
62
|
+
'patterns': 'Pattern',
|
|
63
|
+
'invariants': 'Invariant',
|
|
64
|
+
'decisions': 'Decision',
|
|
65
|
+
'anti-patterns': 'Anti-Pattern',
|
|
66
|
+
'preferences': 'Preference',
|
|
67
|
+
'corrections': 'Correction',
|
|
68
|
+
'lore': 'Lore'
|
|
69
|
+
};
|
|
70
|
+
return reverseMap[category] || (category.charAt(0).toUpperCase() + category.slice(1));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert an OKF concept (frontmatter + body) into an SSSS memory node.
|
|
75
|
+
*/
|
|
76
|
+
export function okfConceptToSsssNode(okfFrontmatter, okfBody, conceptId, options = {}) {
|
|
77
|
+
if (!okfFrontmatter || typeof okfFrontmatter !== 'object') {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const actualTypeMap = { ...DEFAULT_OKF_TYPE_MAP, ...options.typeMap };
|
|
82
|
+
const okfType = okfFrontmatter.type;
|
|
83
|
+
|
|
84
|
+
let category = options.category;
|
|
85
|
+
if (!category) {
|
|
86
|
+
category = actualTypeMap[okfType] || actualTypeMap['*'] || 'facts';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Derive slug
|
|
90
|
+
let slug = conceptId || 'unnamed-concept';
|
|
91
|
+
if (slug.endsWith('.md')) {
|
|
92
|
+
slug = slug.slice(0, -3);
|
|
93
|
+
}
|
|
94
|
+
slug = slug.replace(/^(\.\.?[\/\\])+/, '').replace(/^[\/\\]+/, '');
|
|
95
|
+
slug = slug.replace(/[\/\\]/g, '-').toLowerCase();
|
|
96
|
+
|
|
97
|
+
const title = okfFrontmatter.title || conceptId || 'Untitled Concept';
|
|
98
|
+
const subject = okfFrontmatter.title ? deriveSubject(okfFrontmatter.title) : 'okf.concept';
|
|
99
|
+
const object = okfFrontmatter.type || 'concept';
|
|
100
|
+
|
|
101
|
+
// Parse ISO 8601 timestamp
|
|
102
|
+
let timestamp = new Date().toISOString();
|
|
103
|
+
if (okfFrontmatter.timestamp) {
|
|
104
|
+
if (okfFrontmatter.timestamp instanceof Date) {
|
|
105
|
+
timestamp = okfFrontmatter.timestamp.toISOString();
|
|
106
|
+
} else if (typeof okfFrontmatter.timestamp === 'string') {
|
|
107
|
+
timestamp = okfFrontmatter.timestamp;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const importance = (options.importance !== undefined && options.importance !== null)
|
|
112
|
+
? options.importance
|
|
113
|
+
: ((options.defaultImportance !== undefined && options.defaultImportance !== null)
|
|
114
|
+
? options.defaultImportance
|
|
115
|
+
: 3);
|
|
116
|
+
|
|
117
|
+
const node = {
|
|
118
|
+
type: 'memory',
|
|
119
|
+
slug,
|
|
120
|
+
category,
|
|
121
|
+
title,
|
|
122
|
+
description: okfFrontmatter.description || undefined,
|
|
123
|
+
resource: okfFrontmatter.resource || undefined,
|
|
124
|
+
status: 'active',
|
|
125
|
+
confidence: 0.8,
|
|
126
|
+
importance,
|
|
127
|
+
created: timestamp,
|
|
128
|
+
updated: timestamp,
|
|
129
|
+
last_accessed: new Date().toISOString(),
|
|
130
|
+
source: {
|
|
131
|
+
type: 'okf-import',
|
|
132
|
+
session_id: crypto.randomUUID(),
|
|
133
|
+
agent: 'total-recall-cli',
|
|
134
|
+
evidence_count: 1,
|
|
135
|
+
},
|
|
136
|
+
supersedes: [],
|
|
137
|
+
superseded_by: null,
|
|
138
|
+
contradicts: [],
|
|
139
|
+
tags: Array.isArray(okfFrontmatter.tags) ? okfFrontmatter.tags : (okfFrontmatter.tags ? [okfFrontmatter.tags] : []),
|
|
140
|
+
related: [],
|
|
141
|
+
routes_to_skills: [],
|
|
142
|
+
sentiment_polarity: 'descriptive',
|
|
143
|
+
sentiment_target: subject,
|
|
144
|
+
modality: 'descriptive',
|
|
145
|
+
subject,
|
|
146
|
+
predicate: 'describes',
|
|
147
|
+
object,
|
|
148
|
+
decay: {
|
|
149
|
+
half_life_days: 30,
|
|
150
|
+
access_count: 0,
|
|
151
|
+
},
|
|
152
|
+
schema_version: 2,
|
|
153
|
+
body: okfBody || '',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return node;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Convert an SSSS memory node into an OKF-compliant concept.
|
|
161
|
+
*/
|
|
162
|
+
export function ssssNodeToOkfConcept(ssssNode) {
|
|
163
|
+
if (!ssssNode || typeof ssssNode !== 'object') {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const desc = ssssNode.description || deriveDescription(ssssNode.body);
|
|
168
|
+
|
|
169
|
+
const frontmatter = {
|
|
170
|
+
...ssssNode,
|
|
171
|
+
type: categoryToOkfType(ssssNode.category),
|
|
172
|
+
title: ssssNode.title,
|
|
173
|
+
description: desc || undefined,
|
|
174
|
+
resource: ssssNode.resource || undefined,
|
|
175
|
+
tags: ssssNode.tags || [],
|
|
176
|
+
timestamp: ssssNode.updated || ssssNode.created || new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Clean up SSSS node-specific keys we don't want as top-level frontmatter if they match the body/filepath fields
|
|
180
|
+
delete frontmatter.body;
|
|
181
|
+
delete frontmatter._filePath;
|
|
182
|
+
delete frontmatter._filepath;
|
|
183
|
+
delete frontmatter._file_path;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
frontmatter,
|
|
187
|
+
body: ssssNode.body || '',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Bundle Operations (Placeholders to be fully implemented) ────────────────
|
|
192
|
+
|
|
193
|
+
export async function importBundle(bundlePath, vaultDir, options = {}) {
|
|
194
|
+
const imported = [];
|
|
195
|
+
const skipped = [];
|
|
196
|
+
const errors = [];
|
|
197
|
+
|
|
198
|
+
if (!fs.existsSync(bundlePath)) {
|
|
199
|
+
errors.push({ file: bundlePath, error: 'Bundle directory does not exist' });
|
|
200
|
+
return { imported, skipped, errors };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Helper to walk directory recursively, skipping index.md and log.md
|
|
204
|
+
function walk(dir) {
|
|
205
|
+
let files = [];
|
|
206
|
+
const list = fs.readdirSync(dir, { withFileTypes: true });
|
|
207
|
+
for (const entry of list) {
|
|
208
|
+
const name = entry.name;
|
|
209
|
+
// Skip reserved filenames at any directory level
|
|
210
|
+
if (name === 'index.md' || name === 'log.md') {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const fullPath = path.join(dir, name);
|
|
214
|
+
if (entry.isDirectory()) {
|
|
215
|
+
files = files.concat(walk(fullPath));
|
|
216
|
+
} else if (name.endsWith('.md')) {
|
|
217
|
+
files.push(fullPath);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return files;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let mdFiles = [];
|
|
224
|
+
try {
|
|
225
|
+
mdFiles = walk(bundlePath);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
errors.push({ file: bundlePath, error: err.message });
|
|
228
|
+
return { imported, skipped, errors };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Get existing slugs to handle conflicts
|
|
232
|
+
let existingSlugs = new Set();
|
|
233
|
+
try {
|
|
234
|
+
const existing = getNodes(vaultDir);
|
|
235
|
+
existingSlugs = new Set(existing.map(n => n.slug));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// If vault dir doesn't exist yet, it's fine, we'll create it during writes
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const onConflict = options.onConflict || 'warn';
|
|
241
|
+
|
|
242
|
+
for (const filePath of mdFiles) {
|
|
243
|
+
const relPath = path.relative(bundlePath, filePath);
|
|
244
|
+
let raw;
|
|
245
|
+
try {
|
|
246
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
247
|
+
} catch (err) {
|
|
248
|
+
errors.push({ file: relPath, error: `Failed to read file: ${err.message}` });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let parsed;
|
|
253
|
+
try {
|
|
254
|
+
parsed = matter(raw);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
errors.push({ file: relPath, error: `Failed to parse YAML frontmatter: ${err.message}` });
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Skip if there's no frontmatter data or if the required OKF 'type' field is missing
|
|
261
|
+
if (!parsed || !parsed.data || Object.keys(parsed.data).length === 0 || !parsed.data.type) {
|
|
262
|
+
skipped.push({ file: relPath, reason: 'missing frontmatter or type field' });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const node = okfConceptToSsssNode(parsed.data, parsed.content, relPath, options);
|
|
267
|
+
if (!node) {
|
|
268
|
+
skipped.push({ file: relPath, reason: 'failed to map to SSSS node' });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Handle slug conflict
|
|
273
|
+
if (existingSlugs.has(node.slug)) {
|
|
274
|
+
if (onConflict === 'skip') {
|
|
275
|
+
skipped.push({ file: relPath, reason: `conflict (slug ${node.slug} exists)` });
|
|
276
|
+
continue;
|
|
277
|
+
} else if (onConflict === 'warn') {
|
|
278
|
+
console.warn(`[OKF Import Warning] Slug collision: '${node.slug}' already exists. Overwriting.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const writeResult = writeNodeValidated(node, vaultDir, {
|
|
284
|
+
dryRun: options.dryRun || false,
|
|
285
|
+
agentRole: options.agentRole || 'admin'
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (!writeResult.success) {
|
|
289
|
+
let errMsg = writeResult.error || 'Validation failed';
|
|
290
|
+
if (writeResult.repair?.field_errors) {
|
|
291
|
+
const details = writeResult.repair.field_errors.map(fe => `${fe.field}: ${fe.issue}`).join(', ');
|
|
292
|
+
errMsg += ` (${details})`;
|
|
293
|
+
}
|
|
294
|
+
errors.push({ file: relPath, error: errMsg });
|
|
295
|
+
} else {
|
|
296
|
+
imported.push({ slug: node.slug, file: relPath });
|
|
297
|
+
if (!options.dryRun) {
|
|
298
|
+
existingSlugs.add(node.slug);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
errors.push({ file: relPath, error: `Write error: ${err.message}` });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { imported, skipped, errors };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function exportBundle(vaultDir, outputDir, options = {}) {
|
|
310
|
+
const { execSync } = await import('node:child_process');
|
|
311
|
+
const os = await import('node:os');
|
|
312
|
+
|
|
313
|
+
const format = options.format || 'dir';
|
|
314
|
+
const stripSsss = options.stripSsss || false;
|
|
315
|
+
|
|
316
|
+
let targetDir = outputDir;
|
|
317
|
+
let isTar = format === 'tar.gz';
|
|
318
|
+
let tempDir = null;
|
|
319
|
+
|
|
320
|
+
if (isTar) {
|
|
321
|
+
tempDir = path.join(os.tmpdir(), `okf-export-${crypto.randomUUID()}`);
|
|
322
|
+
targetDir = tempDir;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
326
|
+
|
|
327
|
+
const nodes = getNodes(vaultDir);
|
|
328
|
+
const exported = [];
|
|
329
|
+
|
|
330
|
+
const conceptsByCategory = {};
|
|
331
|
+
|
|
332
|
+
for (const node of nodes) {
|
|
333
|
+
const concept = ssssNodeToOkfConcept(node);
|
|
334
|
+
if (!concept) continue;
|
|
335
|
+
|
|
336
|
+
if (stripSsss) {
|
|
337
|
+
const allowed = ['type', 'title', 'description', 'resource', 'tags', 'timestamp'];
|
|
338
|
+
const stripped = {};
|
|
339
|
+
for (const key of allowed) {
|
|
340
|
+
if (concept.frontmatter[key] !== undefined) {
|
|
341
|
+
stripped[key] = concept.frontmatter[key];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
concept.frontmatter = stripped;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const category = node.category || 'facts';
|
|
348
|
+
const catDir = path.join(targetDir, category);
|
|
349
|
+
fs.mkdirSync(catDir, { recursive: true });
|
|
350
|
+
|
|
351
|
+
const filename = `${node.slug}.md`;
|
|
352
|
+
const destPath = path.join(catDir, filename);
|
|
353
|
+
|
|
354
|
+
const fileContent = safeStringify(concept.body, concept.frontmatter);
|
|
355
|
+
fs.writeFileSync(destPath, fileContent, 'utf8');
|
|
356
|
+
|
|
357
|
+
exported.push({ slug: node.slug, file: path.join(category, filename) });
|
|
358
|
+
|
|
359
|
+
if (!conceptsByCategory[category]) {
|
|
360
|
+
conceptsByCategory[category] = [];
|
|
361
|
+
}
|
|
362
|
+
conceptsByCategory[category].push({
|
|
363
|
+
title: node.title,
|
|
364
|
+
relPath: `./${category}/${filename}`,
|
|
365
|
+
description: concept.frontmatter.description || ''
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Generate index.md
|
|
370
|
+
let indexContent = '# Knowledge Bundle Index\n\n';
|
|
371
|
+
for (const [category, list] of Object.entries(conceptsByCategory)) {
|
|
372
|
+
const catHeader = category.charAt(0).toUpperCase() + category.slice(1);
|
|
373
|
+
indexContent += `## ${catHeader}\n`;
|
|
374
|
+
for (const item of list) {
|
|
375
|
+
const descSuffix = item.description ? ` - ${item.description}` : '';
|
|
376
|
+
indexContent += `- [${item.title}](${item.relPath})${descSuffix}\n`;
|
|
377
|
+
}
|
|
378
|
+
indexContent += '\n';
|
|
379
|
+
}
|
|
380
|
+
fs.writeFileSync(path.join(targetDir, 'index.md'), indexContent, 'utf8');
|
|
381
|
+
|
|
382
|
+
// Generate log.md
|
|
383
|
+
let logContent = '# Bundle Update History\n\n';
|
|
384
|
+
const auditPath = path.join(vaultDir, '.events', 'audit.jsonl');
|
|
385
|
+
if (fs.existsSync(auditPath)) {
|
|
386
|
+
try {
|
|
387
|
+
const lines = fs.readFileSync(auditPath, 'utf8').trim().split('\n');
|
|
388
|
+
for (const line of lines) {
|
|
389
|
+
if (!line) continue;
|
|
390
|
+
const event = JSON.parse(line);
|
|
391
|
+
const date = event.ts ? new Date(event.ts).toISOString().replace(/\.\d+Z$/, 'Z') : 'Unknown Date';
|
|
392
|
+
const operation = event.payload?.resolved_type || 'mutation';
|
|
393
|
+
logContent += `- **${date}**: Operation '${operation}' committed at \`${event.subject || event.path || ''}\`\n`;
|
|
394
|
+
}
|
|
395
|
+
} catch (err) {
|
|
396
|
+
logContent += `Failed to parse audit history: ${err.message}\n`;
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
logContent += 'No update history available.\n';
|
|
400
|
+
}
|
|
401
|
+
fs.writeFileSync(path.join(targetDir, 'log.md'), logContent, 'utf8');
|
|
402
|
+
|
|
403
|
+
if (isTar) {
|
|
404
|
+
const tarFile = outputDir.endsWith('.tar.gz') ? outputDir : `${outputDir}.tar.gz`;
|
|
405
|
+
fs.mkdirSync(path.dirname(tarFile), { recursive: true });
|
|
406
|
+
execSync(`tar -czf "${tarFile}" -C "${targetDir}" .`);
|
|
407
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
exported,
|
|
412
|
+
indexGenerated: true,
|
|
413
|
+
logGenerated: true
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function lintOkfCompliance(vaultDir, options = {}) {
|
|
418
|
+
const strict = options.strict || false;
|
|
419
|
+
const nodes = getNodes(vaultDir);
|
|
420
|
+
|
|
421
|
+
const warnings = [];
|
|
422
|
+
const errors = [];
|
|
423
|
+
|
|
424
|
+
for (const node of nodes) {
|
|
425
|
+
const relPath = `${node.category}/${node.slug}.md`;
|
|
426
|
+
|
|
427
|
+
// Check title
|
|
428
|
+
if (!node.title) {
|
|
429
|
+
const msg = `Missing title in ${relPath}`;
|
|
430
|
+
if (strict) errors.push({ file: relPath, message: msg });
|
|
431
|
+
else warnings.push({ file: relPath, message: msg });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Check description
|
|
435
|
+
if (!node.description) {
|
|
436
|
+
const msg = `Missing description in ${relPath}`;
|
|
437
|
+
if (strict) errors.push({ file: relPath, message: msg });
|
|
438
|
+
else warnings.push({ file: relPath, message: msg });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check tags
|
|
442
|
+
if (!node.tags || !Array.isArray(node.tags) || node.tags.length === 0) {
|
|
443
|
+
const msg = `Missing or empty tags in ${relPath}`;
|
|
444
|
+
if (strict) errors.push({ file: relPath, message: msg });
|
|
445
|
+
else warnings.push({ file: relPath, message: msg });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check updated/created timestamp
|
|
449
|
+
if (!node.updated && !node.created) {
|
|
450
|
+
const msg = `Missing updated/created timestamp in ${relPath}`;
|
|
451
|
+
if (strict) errors.push({ file: relPath, message: msg });
|
|
452
|
+
else warnings.push({ file: relPath, message: msg });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const pass = errors.length === 0;
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
total: nodes.length,
|
|
460
|
+
pass,
|
|
461
|
+
warnings,
|
|
462
|
+
errors
|
|
463
|
+
};
|
|
464
|
+
}
|
package/src/core/schema.mjs
CHANGED
|
@@ -23,6 +23,8 @@ export const MemoryNodeSchema = z.object({
|
|
|
23
23
|
slug: z.string(),
|
|
24
24
|
category: z.string(),
|
|
25
25
|
title: z.string(),
|
|
26
|
+
description: z.string().optional(),
|
|
27
|
+
resource: z.string().optional(),
|
|
26
28
|
status: z.enum(['active', 'superseded', 'deprecated', 'draft']),
|
|
27
29
|
confidence: z.number().min(0).max(1).optional(),
|
|
28
30
|
importance: z.number().int().min(1).max(5).optional(),
|
package/src/core/surface.mjs
CHANGED
|
@@ -10,13 +10,29 @@ import {
|
|
|
10
10
|
} from './memory-layers.mjs';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Extract [[slug]] wikilink references from body text.
|
|
13
|
+
* Extract [[slug]] wikilink references and relative Markdown link targets from body text.
|
|
14
14
|
* Native TR link resolution; Obsidian renders them as graph edges.
|
|
15
15
|
*/
|
|
16
16
|
export function extractWikilinks(body) {
|
|
17
17
|
if (!body) return [];
|
|
18
18
|
const matches = body.match(/\[\[([^\]]+)\]\]/g) || [];
|
|
19
|
-
|
|
19
|
+
const wikilinks = matches.map(m => m.slice(2, -2).split('|')[0].trim());
|
|
20
|
+
|
|
21
|
+
const mdLinkMatches = body.matchAll(/\[([^\]]*)\]\(([^)]+)\)/g);
|
|
22
|
+
const mdLinks = [];
|
|
23
|
+
for (const match of mdLinkMatches) {
|
|
24
|
+
const url = match[2].trim();
|
|
25
|
+
if (/^https?:\/\//i.test(url)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const base = path.basename(url);
|
|
29
|
+
const targetSlug = base.endsWith('.md') ? base.slice(0, -3) : base;
|
|
30
|
+
if (targetSlug) {
|
|
31
|
+
mdLinks.push(targetSlug);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [...new Set([...wikilinks, ...mdLinks])];
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
/**
|
|
@@ -564,7 +580,8 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
|
|
|
564
580
|
category: n.category,
|
|
565
581
|
status: n.status,
|
|
566
582
|
confidence: n.confidence,
|
|
567
|
-
memory_layer: inferMemoryLayer(n)
|
|
583
|
+
memory_layer: inferMemoryLayer(n),
|
|
584
|
+
links: extractWikilinks(n.body || '')
|
|
568
585
|
}));
|
|
569
586
|
atomicWrite(path.join(derivedDir, 'graph-index.jsonl'), graphIndex.map(n => JSON.stringify(n)).join('\n'));
|
|
570
587
|
atomicWrite(
|