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.
Files changed (46) hide show
  1. package/bin/total-recall.mjs +3 -2
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/{index-CRzjQLu7.js → index-WT3gs-x6.js} +24 -24
  4. package/frontend/dist/index.html +1 -1
  5. package/package.json +2 -2
  6. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  7. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  8. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  9. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  10. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
  11. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +1 -1
  12. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
  13. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +1 -1
  14. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3165 -87
  15. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +1 -1
  16. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
  17. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +1 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +32 -12
  19. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +294 -46
  20. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
  21. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +2 -2
  22. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +190 -9
  23. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
  24. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  25. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  26. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  27. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  28. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
  29. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  30. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  31. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
  32. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3087 -71
  34. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
  35. package/src/cli/compact.mjs +108 -0
  36. package/src/cli/context.mjs +52 -23
  37. package/src/cli/forget.mjs +2 -0
  38. package/src/cli/release.mjs +386 -0
  39. package/src/core/cli-commands.mjs +2 -0
  40. package/src/core/context-compactor.mjs +143 -0
  41. package/src/core/preflight-gate.mjs +2 -2
  42. package/src/core/preflight-receipt.mjs +1 -1
  43. package/src/core/surface.mjs +1 -1
  44. package/src/server/invoke-durability.mjs +39 -0
  45. package/src/server/invoke-write-child.mjs +25 -0
  46. 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
- * festech-modular, festech.live and ultrachat-ai-powered each carried pages
8
- * three months and 78-149 commits out of date while still looking authoritative.
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-v4.1-flash', provider: 'openrouter' };
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 };