total-recall-brain 3.41.4 → 3.41.5
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 +3 -2
- package/docs/reference/cli-reference.md +18 -0
- package/frontend/dist/assets/{index-CRzjQLu7.js → index-WT3gs-x6.js} +24 -24
- package/frontend/dist/index.html +1 -1
- package/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3165 -87
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +32 -12
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +294 -46
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +190 -9
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3087 -71
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
- package/src/cli/compact.mjs +108 -0
- package/src/cli/context.mjs +52 -23
- package/src/cli/forget.mjs +2 -0
- package/src/cli/release.mjs +386 -0
- package/src/core/cli-commands.mjs +2 -0
- package/src/core/context-compactor.mjs +143 -0
- package/src/core/preflight-gate.mjs +2 -2
- package/src/core/preflight-receipt.mjs +1 -1
- package/src/core/surface.mjs +1 -1
- package/src/server/invoke-durability.mjs +39 -0
- package/src/server/invoke-write-child.mjs +25 -0
- package/src/server/routes/invoke.mjs +7 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenWiki Documentation & Link Hygiene Linter.
|
|
3
|
+
*
|
|
4
|
+
* Scans markdown hubs across workspace or all vaults, detecting:
|
|
5
|
+
* - Broken relative links and dead file targets
|
|
6
|
+
* - Missing or broken wikilinks
|
|
7
|
+
* - Missing title headers or empty pages
|
|
8
|
+
* - Orphaned hubs with zero inbound or outbound links
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { discoverAllHubs } from './mesh-vaults.mjs';
|
|
13
|
+
|
|
14
|
+
export async function lintWikiHubs({
|
|
15
|
+
allVaults = false,
|
|
16
|
+
node = null,
|
|
17
|
+
cwd = process.cwd(),
|
|
18
|
+
} = {}) {
|
|
19
|
+
const hubsResult = await discoverAllHubs({ allVaults, node, cwd });
|
|
20
|
+
const allHubs = hubsResult.hubs;
|
|
21
|
+
|
|
22
|
+
const existingFileSet = new Set(allHubs.map((h) => path.resolve(h.path)));
|
|
23
|
+
const baseNameMap = new Map();
|
|
24
|
+
for (const h of allHubs) {
|
|
25
|
+
baseNameMap.set(h.fileName.toLowerCase(), h.path);
|
|
26
|
+
baseNameMap.set(h.fileName.replace(/\.md$/, '').toLowerCase(), h.path);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const issues = [];
|
|
30
|
+
let totalLinks = 0;
|
|
31
|
+
let brokenLinksCount = 0;
|
|
32
|
+
const inboundCount = new Map(allHubs.map((h) => [h.path, 0]));
|
|
33
|
+
const outboundCount = new Map(allHubs.map((h) => [h.path, 0]));
|
|
34
|
+
|
|
35
|
+
for (const hub of allHubs) {
|
|
36
|
+
let content;
|
|
37
|
+
try {
|
|
38
|
+
content = fs.readFileSync(hub.path, 'utf8');
|
|
39
|
+
} catch {
|
|
40
|
+
issues.push({
|
|
41
|
+
type: 'unreadable_file',
|
|
42
|
+
file: hub.path,
|
|
43
|
+
message: 'Could not read markdown file',
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!content.trim()) {
|
|
49
|
+
issues.push({
|
|
50
|
+
type: 'empty_hub',
|
|
51
|
+
file: hub.path,
|
|
52
|
+
message: 'Hub markdown file is completely empty',
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check title header
|
|
58
|
+
if (!content.match(/^#\s+.+/m)) {
|
|
59
|
+
issues.push({
|
|
60
|
+
type: 'missing_title',
|
|
61
|
+
file: hub.path,
|
|
62
|
+
message: 'Missing level 1 header (# Title)',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hubDir = path.dirname(hub.path);
|
|
67
|
+
|
|
68
|
+
// 1. Check relative markdown links: [Text](target.md)
|
|
69
|
+
const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
70
|
+
let match;
|
|
71
|
+
while ((match = mdLinkRegex.exec(content)) !== null) {
|
|
72
|
+
const linkText = match[1];
|
|
73
|
+
const rawUrl = match[2].trim().split('#')[0]; // exclude anchor
|
|
74
|
+
if (!rawUrl || rawUrl.startsWith('http://') || rawUrl.startsWith('https://') || rawUrl.startsWith('mailto:')) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
totalLinks++;
|
|
79
|
+
outboundCount.set(hub.path, (outboundCount.get(hub.path) || 0) + 1);
|
|
80
|
+
|
|
81
|
+
// Resolve relative to hub file directory
|
|
82
|
+
const resolvedTarget = path.resolve(hubDir, rawUrl);
|
|
83
|
+
if (fs.existsSync(resolvedTarget)) {
|
|
84
|
+
inboundCount.set(resolvedTarget, (inboundCount.get(resolvedTarget) || 0) + 1);
|
|
85
|
+
} else {
|
|
86
|
+
// Fallback: check if matches another hub by basename
|
|
87
|
+
const targetBase = path.basename(rawUrl).toLowerCase();
|
|
88
|
+
if (baseNameMap.has(targetBase)) {
|
|
89
|
+
const alternate = baseNameMap.get(targetBase);
|
|
90
|
+
inboundCount.set(alternate, (inboundCount.get(alternate) || 0) + 1);
|
|
91
|
+
} else {
|
|
92
|
+
brokenLinksCount++;
|
|
93
|
+
issues.push({
|
|
94
|
+
type: 'broken_link',
|
|
95
|
+
file: hub.path,
|
|
96
|
+
linkText,
|
|
97
|
+
target: rawUrl,
|
|
98
|
+
message: `Target file not found: ${rawUrl}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Check wikilinks: [[Target]]
|
|
105
|
+
const wikiRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
106
|
+
while ((match = wikiRegex.exec(content)) !== null) {
|
|
107
|
+
const targetName = match[1].trim().toLowerCase();
|
|
108
|
+
totalLinks++;
|
|
109
|
+
outboundCount.set(hub.path, (outboundCount.get(hub.path) || 0) + 1);
|
|
110
|
+
|
|
111
|
+
if (baseNameMap.has(targetName) || baseNameMap.has(`${targetName}.md`)) {
|
|
112
|
+
const targetPath = baseNameMap.get(targetName) || baseNameMap.get(`${targetName}.md`);
|
|
113
|
+
inboundCount.set(targetPath, (inboundCount.get(targetPath) || 0) + 1);
|
|
114
|
+
} else {
|
|
115
|
+
brokenLinksCount++;
|
|
116
|
+
issues.push({
|
|
117
|
+
type: 'broken_wikilink',
|
|
118
|
+
file: hub.path,
|
|
119
|
+
target: match[1].trim(),
|
|
120
|
+
message: `Wikilink target not found: [[${match[1].trim()}]]`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Detect orphaned pages
|
|
127
|
+
const orphans = [];
|
|
128
|
+
for (const hub of allHubs) {
|
|
129
|
+
const inbound = inboundCount.get(hub.path) || 0;
|
|
130
|
+
const outbound = outboundCount.get(hub.path) || 0;
|
|
131
|
+
if (inbound === 0 && outbound === 0 && allHubs.length > 1) {
|
|
132
|
+
orphans.push({
|
|
133
|
+
file: hub.path,
|
|
134
|
+
title: hub.title,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const score = totalLinks === 0
|
|
140
|
+
? (issues.length === 0 ? 100 : Math.max(50, 100 - issues.length * 10))
|
|
141
|
+
: Math.max(0, Math.round(((totalLinks - brokenLinksCount) / totalLinks) * 100));
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: issues.length === 0,
|
|
145
|
+
healthScore: score,
|
|
146
|
+
totalHubs: allHubs.length,
|
|
147
|
+
totalLinks,
|
|
148
|
+
brokenLinksCount,
|
|
149
|
+
orphanCount: orphans.length,
|
|
150
|
+
issues,
|
|
151
|
+
orphans,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Vault and Mesh Discovery Engine for OpenWiki.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Total Recall Mesh plugin dynamically (zero hardcoded hosts/IPs/devices).
|
|
5
|
+
* Discovers local vaults, registered project vaults, and live online mesh peers.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
11
|
+
import { readProjects } from './refresh.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Returns the path to the global vault directory.
|
|
15
|
+
*/
|
|
16
|
+
export function getGlobalVaultPath(home = os.homedir()) {
|
|
17
|
+
return path.join(home, '.agent', 'skills', 'total-recall', 'memory-vault');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Discovers all active vault roots on the current machine dynamically:
|
|
22
|
+
* - Global memory vault
|
|
23
|
+
* - Every registered project repository's memory vault
|
|
24
|
+
* - Every registered project repository's openwiki projection directory
|
|
25
|
+
*/
|
|
26
|
+
export function discoverLocalVaults({ home = os.homedir() } = {}) {
|
|
27
|
+
const vaults = [];
|
|
28
|
+
|
|
29
|
+
// 1. Global Vault
|
|
30
|
+
const globalVault = getGlobalVaultPath(home);
|
|
31
|
+
if (fs.existsSync(globalVault)) {
|
|
32
|
+
vaults.push({
|
|
33
|
+
type: 'global',
|
|
34
|
+
name: 'global-vault',
|
|
35
|
+
root: globalVault,
|
|
36
|
+
isVault: true,
|
|
37
|
+
hasOpenWikiDir: fs.existsSync(path.join(globalVault, 'openwiki-articles')) ||
|
|
38
|
+
fs.existsSync(path.join(globalVault, 'openwiki-hubs')),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Project Vaults & Repositories from project-registry
|
|
43
|
+
let projects = [];
|
|
44
|
+
try {
|
|
45
|
+
projects = readProjects();
|
|
46
|
+
} catch {
|
|
47
|
+
projects = [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const proj of projects) {
|
|
51
|
+
const projectVault = path.join(proj.root, '.agent', 'skills', 'total-recall', 'memory-vault');
|
|
52
|
+
const projectOpenWiki = path.join(proj.root, 'openwiki');
|
|
53
|
+
vaults.push({
|
|
54
|
+
type: 'project',
|
|
55
|
+
name: proj.name,
|
|
56
|
+
root: proj.root,
|
|
57
|
+
vaultPath: fs.existsSync(projectVault) ? projectVault : null,
|
|
58
|
+
openwikiPath: fs.existsSync(projectOpenWiki) ? projectOpenWiki : null,
|
|
59
|
+
hasOpenWikiDir: fs.existsSync(projectOpenWiki),
|
|
60
|
+
isVault: fs.existsSync(projectVault),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return vaults;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Dynamically queries the mesh plugin for live mesh peers.
|
|
69
|
+
* Zero hardcoded hostnames, IPs, or personal user configurations.
|
|
70
|
+
*/
|
|
71
|
+
export function getMeshNodes() {
|
|
72
|
+
try {
|
|
73
|
+
const stdout = execFileSync('total-recall', ['mesh', 'status'], {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
timeout: 6000,
|
|
76
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const lines = stdout.split('\n');
|
|
80
|
+
const jsonLine = lines.find((l) => l.trim().startsWith('{') && l.includes('"nodes"'));
|
|
81
|
+
if (!jsonLine) return [];
|
|
82
|
+
|
|
83
|
+
const parsed = JSON.parse(jsonLine);
|
|
84
|
+
if (!Array.isArray(parsed.nodes)) return [];
|
|
85
|
+
|
|
86
|
+
return parsed.nodes
|
|
87
|
+
.filter((n) => n && n.ip && typeof n.ip === 'string')
|
|
88
|
+
.map((n) => ({
|
|
89
|
+
hostname: n.os_hostname || n.hostname || n.title || n.ip,
|
|
90
|
+
ip: n.ip,
|
|
91
|
+
ips: Array.isArray(n.ips) ? n.ips : [n.ip],
|
|
92
|
+
self: Boolean(n.self),
|
|
93
|
+
online: Boolean(n.online),
|
|
94
|
+
os: n.os || 'unknown',
|
|
95
|
+
}));
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Execute a command on a remote mesh node via Total Recall mesh plugin.
|
|
103
|
+
* Guarded against self-execution (never ssh a machine into itself).
|
|
104
|
+
*/
|
|
105
|
+
export function execOnMeshNode(nodeTarget, commandStr, { timeoutMs = 25000 } = {}) {
|
|
106
|
+
if (!nodeTarget || typeof nodeTarget !== 'string') {
|
|
107
|
+
throw new Error('A valid mesh node identifier or IP is required');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Verify against dynamic mesh self status
|
|
111
|
+
const nodes = getMeshNodes();
|
|
112
|
+
const matched = nodes.find(
|
|
113
|
+
(n) => n.ip === nodeTarget || n.hostname.toLowerCase() === nodeTarget.toLowerCase()
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (matched?.self) {
|
|
117
|
+
throw new Error(`Target node '${nodeTarget}' is the local host. Do not mesh exec into self.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result = spawnSync('total-recall', ['mesh', 'exec', nodeTarget, commandStr], {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
timeout: timeoutMs,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ok: result.status === 0,
|
|
127
|
+
status: result.status,
|
|
128
|
+
stdout: result.stdout || '',
|
|
129
|
+
stderr: result.stderr || '',
|
|
130
|
+
error: result.error ? result.error.message : null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Scans a directory for OpenWiki markdown hub files.
|
|
136
|
+
*/
|
|
137
|
+
export function scanDirectoryHubs(targetDir, { label = '', repoName = '' } = {}) {
|
|
138
|
+
const hubs = [];
|
|
139
|
+
if (!fs.existsSync(targetDir)) return hubs;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
143
|
+
for (const ent of entries) {
|
|
144
|
+
if (ent.isFile() && ent.name.endsWith('.md')) {
|
|
145
|
+
const fullPath = path.join(targetDir, ent.name);
|
|
146
|
+
try {
|
|
147
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
148
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
149
|
+
const title = titleMatch ? titleMatch[1].trim() : ent.name.replace(/\.md$/, '');
|
|
150
|
+
const lines = content.split('\n').length;
|
|
151
|
+
hubs.push({
|
|
152
|
+
title,
|
|
153
|
+
fileName: ent.name,
|
|
154
|
+
path: fullPath,
|
|
155
|
+
repoName: repoName || path.basename(path.dirname(targetDir)),
|
|
156
|
+
sourceType: label || 'openwiki',
|
|
157
|
+
lines,
|
|
158
|
+
size_bytes: Buffer.byteLength(content, 'utf8'),
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore unreadable files
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// directory read error
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return hubs;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Universal Hubs Discovery across:
|
|
174
|
+
* - Current cwd `./openwiki`
|
|
175
|
+
* - All registered project vaults (`--all-vaults`)
|
|
176
|
+
* - Global memory vault (`openwiki-*` categories)
|
|
177
|
+
* - Remote mesh node (`--node <target>`)
|
|
178
|
+
*/
|
|
179
|
+
export async function discoverAllHubs({
|
|
180
|
+
allVaults = false,
|
|
181
|
+
node = null,
|
|
182
|
+
cwd = process.cwd(),
|
|
183
|
+
} = {}) {
|
|
184
|
+
// If target is a remote mesh node, delegate through mesh
|
|
185
|
+
if (node && node !== 'local' && node !== 'self') {
|
|
186
|
+
const nodes = getMeshNodes();
|
|
187
|
+
const selfNode = nodes.find((n) => n.self);
|
|
188
|
+
const isSelf = selfNode && (selfNode.ip === node || selfNode.hostname.toLowerCase() === node.toLowerCase());
|
|
189
|
+
|
|
190
|
+
if (!isSelf) {
|
|
191
|
+
const meshRes = execOnMeshNode(node, `total-recall openwiki hubs ${allVaults ? '--all-vaults' : ''} --json`);
|
|
192
|
+
if (meshRes.ok) {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(meshRes.stdout);
|
|
195
|
+
return {
|
|
196
|
+
node,
|
|
197
|
+
isRemote: true,
|
|
198
|
+
hubs: parsed.hubs || [],
|
|
199
|
+
count: parsed.count || (parsed.hubs ? parsed.hubs.length : 0),
|
|
200
|
+
};
|
|
201
|
+
} catch {
|
|
202
|
+
throw new Error(`Invalid JSON response from mesh node ${node}: ${meshRes.stdout}`);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
throw new Error(`Mesh exec failed on node ${node}: ${meshRes.stderr || meshRes.error}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const allHubs = [];
|
|
211
|
+
|
|
212
|
+
// 1. Current working directory openwiki
|
|
213
|
+
const localOpenWiki = path.resolve(cwd, 'openwiki');
|
|
214
|
+
const localHubs = scanDirectoryHubs(localOpenWiki, {
|
|
215
|
+
label: 'current-workspace',
|
|
216
|
+
repoName: path.basename(cwd),
|
|
217
|
+
});
|
|
218
|
+
allHubs.push(...localHubs);
|
|
219
|
+
|
|
220
|
+
// If not all vaults requested, return local hubs
|
|
221
|
+
if (!allVaults) {
|
|
222
|
+
return {
|
|
223
|
+
node: 'local',
|
|
224
|
+
isRemote: false,
|
|
225
|
+
hubs: allHubs,
|
|
226
|
+
count: allHubs.length,
|
|
227
|
+
scope: 'workspace',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. Scan all project vaults across the machine
|
|
232
|
+
const localVaults = discoverLocalVaults();
|
|
233
|
+
for (const vault of localVaults) {
|
|
234
|
+
if (vault.type === 'project' && vault.openwikiPath) {
|
|
235
|
+
if (path.resolve(vault.openwikiPath) === path.resolve(localOpenWiki)) continue;
|
|
236
|
+
const projHubs = scanDirectoryHubs(vault.openwikiPath, {
|
|
237
|
+
label: 'project-openwiki',
|
|
238
|
+
repoName: vault.name,
|
|
239
|
+
});
|
|
240
|
+
allHubs.push(...projHubs);
|
|
241
|
+
} else if (vault.type === 'global') {
|
|
242
|
+
const gHubsDir = path.join(vault.root, 'openwiki-hubs');
|
|
243
|
+
const gArticlesDir = path.join(vault.root, 'openwiki-articles');
|
|
244
|
+
if (fs.existsSync(gHubsDir)) {
|
|
245
|
+
allHubs.push(...scanDirectoryHubs(gHubsDir, { label: 'global-vault-hubs', repoName: 'global-vault' }));
|
|
246
|
+
}
|
|
247
|
+
if (fs.existsSync(gArticlesDir)) {
|
|
248
|
+
allHubs.push(...scanDirectoryHubs(gArticlesDir, { label: 'global-vault-articles', repoName: 'global-vault' }));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
node: 'local',
|
|
255
|
+
isRemote: false,
|
|
256
|
+
hubs: allHubs,
|
|
257
|
+
count: allHubs.length,
|
|
258
|
+
scope: 'all-vaults',
|
|
259
|
+
vaultsScanned: localVaults.length,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* The generated `openwiki/` pages are a per-repository projection: they are
|
|
5
5
|
* written into <repo>/openwiki/ and describe that repository's code. They rot
|
|
6
6
|
* silently, because nothing in the repository is obliged to regenerate them —
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* repositories have carried pages three months and 78-149 commits out of date
|
|
8
|
+
* while still looking authoritative.
|
|
9
9
|
*
|
|
10
10
|
* The driver belongs here rather than in every repository: the plugin is one of
|
|
11
11
|
* the bundled defaults served from the shared brain, so one implementation
|
|
@@ -21,7 +21,7 @@ import fs from 'node:fs';
|
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
|
-
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
25
25
|
import { loadOpenWikiConfig } from './config.mjs';
|
|
26
26
|
|
|
27
27
|
const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
@@ -82,7 +82,16 @@ export function hasWiki(root) {
|
|
|
82
82
|
* since the recorded generation, which is the same measure the per-repo driver
|
|
83
83
|
* used. Uncommitted work is not counted, so an open edit does not read as drift.
|
|
84
84
|
*/
|
|
85
|
+
export function isGitRepo(root, { git = spawnSync } = {}) {
|
|
86
|
+
if (fs.existsSync(path.join(root, '.git'))) return true;
|
|
87
|
+
const res = git('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
|
|
88
|
+
return !res.error && res.status === 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
85
91
|
export function staleness(root, { git = spawnSync } = {}) {
|
|
92
|
+
if (!isGitRepo(root, { git })) {
|
|
93
|
+
return { root, head: '', generatedAt: null, generatedStatus: null, files: null, lastCodeChange: null, commitsSince: 0, stale: false };
|
|
94
|
+
}
|
|
86
95
|
const marker = path.join(root, 'openwiki', '.last-update.json');
|
|
87
96
|
let head = '';
|
|
88
97
|
const headRun = git('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
@@ -171,11 +180,19 @@ export function resolveKey({ provider = null, keys = null, run = spawnSync } = {
|
|
|
171
180
|
export function resolveGenerator({ pluginRoot = null, run = spawnSync, env = process.env, home = os.homedir(), targetVersion = null } = {}) {
|
|
172
181
|
const root = pluginRoot !== null ? pluginRoot : PLUGIN_ROOT;
|
|
173
182
|
if (root) {
|
|
183
|
+
const bundled = path.join(root, 'vendor', 'openwiki.mjs');
|
|
184
|
+
if (fs.existsSync(bundled)) {
|
|
185
|
+
return { command: process.execPath, args: [bundled], via: 'bundled plugin generator' };
|
|
186
|
+
}
|
|
174
187
|
const candidate = path.join(root, 'node_modules', '.bin', 'openwiki');
|
|
175
188
|
if (fs.existsSync(candidate)) return { command: candidate, args: [], via: 'plugin local node_modules/.bin' };
|
|
176
189
|
}
|
|
177
190
|
|
|
178
191
|
if (pluginRoot === null) {
|
|
192
|
+
const localBundled = path.join(process.cwd(), 'vendor', 'openwiki.mjs');
|
|
193
|
+
if (fs.existsSync(localBundled)) {
|
|
194
|
+
return { command: process.execPath, args: [localBundled], via: 'bundled plugin generator' };
|
|
195
|
+
}
|
|
179
196
|
const local = path.join(process.cwd(), 'node_modules', '.bin', 'openwiki');
|
|
180
197
|
if (fs.existsSync(local)) return { command: local, args: [], via: 'local node_modules/.bin' };
|
|
181
198
|
}
|
|
@@ -235,6 +252,9 @@ export function stillGenerating(root) {
|
|
|
235
252
|
export function refreshProject(project, {
|
|
236
253
|
key, model = null, provider = null, binary = null, binaryArgs = [], timeoutMs = 30 * 60 * 1000, run = spawnSync, now = Date.now(), targetVersion = null, pluginRoot = PLUGIN_ROOT,
|
|
237
254
|
} = {}) {
|
|
255
|
+
if (!isGitRepo(project.root, { git: run })) {
|
|
256
|
+
return { ...project, action: 'skipped', reason: 'not a git repository' };
|
|
257
|
+
}
|
|
238
258
|
const before = staleness(project.root, { git: run });
|
|
239
259
|
const uninitialized = !hasWiki(project.root);
|
|
240
260
|
|
|
@@ -247,7 +267,7 @@ export function refreshProject(project, {
|
|
|
247
267
|
({ command, args, via } = resolved);
|
|
248
268
|
}
|
|
249
269
|
|
|
250
|
-
const config = typeof loadOpenWikiConfig === 'function' ? loadOpenWikiConfig() : { model: 'deepseek/deepseek-
|
|
270
|
+
const config = typeof loadOpenWikiConfig === 'function' ? loadOpenWikiConfig() : { model: 'deepseek/deepseek-chat', provider: 'openrouter' };
|
|
251
271
|
const selectedModel = model || config.model;
|
|
252
272
|
const selectedProvider = provider || config.provider;
|
|
253
273
|
|
|
@@ -336,8 +356,135 @@ export function refreshProject(project, {
|
|
|
336
356
|
return { ...project, action: uninitialized ? 'initialized' : 'refreshed', from: before.generatedAt, to: after.generatedAt, durationMs: Date.now() - startedAt, via, ...after };
|
|
337
357
|
}
|
|
338
358
|
|
|
359
|
+
export async function refreshProjectAsync(project, {
|
|
360
|
+
key, model = null, provider = null, binary = null, binaryArgs = [], timeoutMs = 30 * 60 * 1000, now = Date.now(), targetVersion = null, pluginRoot = PLUGIN_ROOT,
|
|
361
|
+
} = {}) {
|
|
362
|
+
if (!isGitRepo(project.root, { git: spawnSync })) {
|
|
363
|
+
return { ...project, action: 'skipped', reason: 'not a git repository' };
|
|
364
|
+
}
|
|
365
|
+
const before = staleness(project.root, { git: spawnSync });
|
|
366
|
+
const uninitialized = !hasWiki(project.root);
|
|
367
|
+
let command = binary, args = binaryArgs, via = binary ? 'explicit' : null;
|
|
368
|
+
if (!command) {
|
|
369
|
+
const resolved = resolveGenerator({ run: spawnSync, targetVersion, pluginRoot });
|
|
370
|
+
if (!resolved.command) {
|
|
371
|
+
return { ...project, action: 'failed', reason: 'no openwiki generator available (no local .bin, no PATH entry, no npx)', ...before };
|
|
372
|
+
}
|
|
373
|
+
({ command, args, via } = resolved);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const config = typeof loadOpenWikiConfig === 'function' ? loadOpenWikiConfig() : { model: 'deepseek/deepseek-chat', provider: 'openrouter' };
|
|
377
|
+
const selectedModel = model || config.model;
|
|
378
|
+
const selectedProvider = provider || config.provider;
|
|
379
|
+
|
|
380
|
+
const modeFlag = uninitialized ? '--init' : '--update';
|
|
381
|
+
const cmdArgs = [...args, modeFlag, '-p'];
|
|
382
|
+
if (selectedModel) {
|
|
383
|
+
cmdArgs.push('--modelId', selectedModel);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (uninitialized) {
|
|
387
|
+
const runFile = path.join(project.root, 'openwiki', '.run.json');
|
|
388
|
+
if (fs.existsSync(runFile)) {
|
|
389
|
+
try { fs.unlinkSync(runFile); } catch {}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const childEnv = { ...process.env };
|
|
394
|
+
if (selectedProvider === 'google') {
|
|
395
|
+
childEnv.GOOGLE_API_KEY = key;
|
|
396
|
+
childEnv.GEMINI_API_KEY = key;
|
|
397
|
+
childEnv.OPENWIKI_PROVIDER = 'gemini';
|
|
398
|
+
delete childEnv.OPENAI_API_KEY;
|
|
399
|
+
delete childEnv.OPENROUTER_API_KEY;
|
|
400
|
+
} else {
|
|
401
|
+
childEnv.OPENROUTER_API_KEY = key;
|
|
402
|
+
childEnv.OPENWIKI_PROVIDER = 'openrouter';
|
|
403
|
+
if (config.max_tokens) {
|
|
404
|
+
childEnv.OPENWIKI_OPENROUTER_MAX_TOKENS = String(config.max_tokens);
|
|
405
|
+
childEnv.OPENWIKI_MAX_OUTPUT_TOKENS = String(config.max_tokens);
|
|
406
|
+
}
|
|
407
|
+
delete childEnv.OPENAI_API_KEY;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const startedAt = now;
|
|
411
|
+
const result = await new Promise((resolve) => {
|
|
412
|
+
const proc = spawn(command, cmdArgs, {
|
|
413
|
+
cwd: project.root,
|
|
414
|
+
env: childEnv,
|
|
415
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
416
|
+
timeout: timeoutMs,
|
|
417
|
+
});
|
|
418
|
+
let stdout = '', stderr = '';
|
|
419
|
+
proc.stdout?.on('data', (d) => { stdout += d; });
|
|
420
|
+
proc.stderr?.on('data', (d) => { stderr += d; });
|
|
421
|
+
proc.on('error', (err) => resolve({ error: err, status: 1, stdout, stderr }));
|
|
422
|
+
proc.on('close', (status) => resolve({ status: status ?? 0, stdout, stderr }));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// OpenWiki generator may drop GitHub workflow files; our system strictly forbids GitHub Actions.
|
|
426
|
+
const workflowDir = path.join(project.root, '.github', 'workflows');
|
|
427
|
+
const workflowFile = path.join(workflowDir, 'openwiki-update.yml');
|
|
428
|
+
if (fs.existsSync(workflowFile)) {
|
|
429
|
+
try { fs.unlinkSync(workflowFile); } catch {}
|
|
430
|
+
}
|
|
431
|
+
if (fs.existsSync(workflowDir)) {
|
|
432
|
+
try {
|
|
433
|
+
const remaining = fs.readdirSync(workflowDir);
|
|
434
|
+
if (remaining.length === 0) fs.rmdirSync(workflowDir);
|
|
435
|
+
} catch {}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const after = staleness(project.root, { git: spawnSync });
|
|
439
|
+
const exitCode = result.error ? (result.error.code || 1) : result.status;
|
|
440
|
+
const advanced = after.generatedAt !== before.generatedAt || (uninitialized && hasWiki(project.root));
|
|
441
|
+
const wrote = pagesWritten(before, after) || (uninitialized && hasWiki(project.root) ? 1 : 0);
|
|
442
|
+
|
|
443
|
+
const outcome = after.generatedStatus === 'interrupted' || stillGenerating(project.root)
|
|
444
|
+
? 'partial'
|
|
445
|
+
: (advanced ? (uninitialized ? 'initialized' : 'refreshed') : 'unchanged');
|
|
446
|
+
|
|
447
|
+
const extractReason = (rawErr, rawOut) => {
|
|
448
|
+
if (result.error?.message) return result.error.message;
|
|
449
|
+
const combined = `${rawErr || ''}\n${rawOut || ''}`.trim();
|
|
450
|
+
if (!combined) return `exit ${exitCode}`;
|
|
451
|
+
const lines = combined.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
452
|
+
const diag = lines.find((l) => l.includes('Error Diagnostics') || l.includes('exited without') || l.includes('Could not restore') || l.includes('Provider returned error') || l.includes('fetch failed'));
|
|
453
|
+
if (diag) return diag.replace(/^\[ERR\]\s*/, '');
|
|
454
|
+
const last = lines[lines.length - 1];
|
|
455
|
+
return last ? last.replace(/^\[ERR\]\s*/, '') : `exit ${exitCode}`;
|
|
456
|
+
};
|
|
457
|
+
const failReason = extractReason(result.stderr, result.stdout);
|
|
458
|
+
|
|
459
|
+
if (exitCode !== 0 && !wrote) {
|
|
460
|
+
return { ...project, action: 'failed', reason: failReason, exitCode, ...after };
|
|
461
|
+
}
|
|
462
|
+
if (!advanced && !wrote) {
|
|
463
|
+
return { ...project, action: 'failed', reason: failReason || `command exited ${exitCode} and no page changed`, exitCode, ...after };
|
|
464
|
+
}
|
|
465
|
+
if (outcome === 'partial') {
|
|
466
|
+
return {
|
|
467
|
+
...project,
|
|
468
|
+
action: 'partial',
|
|
469
|
+
reason: `${wrote} page(s) rewritten, but the run did not finish (the generator was cut off); it resumes on the next refresh`,
|
|
470
|
+
exitCode,
|
|
471
|
+
from: before.generatedAt,
|
|
472
|
+
to: after.generatedAt,
|
|
473
|
+
via,
|
|
474
|
+
...after,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (exitCode !== 0) {
|
|
478
|
+
return { ...project, action: 'failed', reason: failReason, exitCode, ...after };
|
|
479
|
+
}
|
|
480
|
+
return { ...project, action: uninitialized ? 'initialized' : 'refreshed', from: before.generatedAt, to: after.generatedAt, durationMs: Date.now() - startedAt, via, ...after };
|
|
481
|
+
}
|
|
482
|
+
|
|
339
483
|
/** Is a refresh due for this project under the configured interval? */
|
|
340
484
|
export function isDue(project, { lockMs = DEFAULT_LOCK_MS, now = Date.now(), git = spawnSync } = {}) {
|
|
485
|
+
if (!isGitRepo(project.root, { git })) {
|
|
486
|
+
return { due: false, skipped: true, reason: 'not a git repository' };
|
|
487
|
+
}
|
|
341
488
|
const state = staleness(project.root, { git });
|
|
342
489
|
if (!hasWiki(project.root)) {
|
|
343
490
|
return { due: true, uninitialized: true, reason: 'uninitialized (missing openwiki/ pages)', ...state };
|