total-recall-brain 3.41.3 → 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 (96) hide show
  1. package/bin/total-recall.mjs +12 -2
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/{index-DFenQQEQ.js → index-WT3gs-x6.js} +75 -70
  4. package/frontend/dist/index.html +1 -1
  5. package/package.json +7 -5
  6. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +2 -2
  7. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
  8. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  9. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +48 -0
  10. package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +42 -0
  11. package/scaffold/.agent/skills/total-recall/plugins/auth/src/mesh-secrets.mjs +18 -0
  12. package/scaffold/.agent/skills/total-recall/plugins/auth/src/routes/secrets.mjs +5 -2
  13. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-carriers.mjs +76 -0
  14. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-prune.mjs +37 -0
  15. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +53 -17
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  17. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3131 -28
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +6 -2
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3132 -29
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +5 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3164 -73
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -3
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/grants.mjs +7 -3
  28. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +61 -38
  29. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +57 -2
  30. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +220 -36
  31. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/server/routes/mesh.mjs +7 -2
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +65 -0
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +59 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +534 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +139 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +5937 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +26 -0
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +376 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +37 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adapters/dsh.mjs +446 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +23 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +398 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +172 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +82 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +90 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +415 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +36 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +35 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/tracker.mjs +133 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +233 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +32 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +845 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/BRICK.md +64 -0
  54. package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/index.mjs +250 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +619 -41
  56. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +3134 -31
  57. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +9 -3
  58. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +413 -14
  59. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +129 -0
  60. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +502 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +375 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +35 -22
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +1225 -364
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3114 -83
  72. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +5 -1
  73. package/src/cli/compact.mjs +108 -0
  74. package/src/cli/config.mjs +3 -0
  75. package/src/cli/connect.mjs +12 -116
  76. package/src/cli/context.mjs +52 -23
  77. package/src/cli/forget.mjs +2 -0
  78. package/src/cli/harness.mjs +0 -1
  79. package/src/cli/plugin/create.mjs +56 -3
  80. package/src/cli/plugin/index.mjs +15 -3
  81. package/src/cli/plugin/monetization.mjs +232 -12
  82. package/src/cli/preflight.mjs +161 -0
  83. package/src/cli/release.mjs +386 -0
  84. package/src/core/cli-commands.mjs +4 -0
  85. package/src/core/context-compactor.mjs +143 -0
  86. package/src/core/context-compiler.mjs +8 -1
  87. package/src/core/default-plugins.mjs +1 -1
  88. package/src/core/meta-harness.mjs +16 -38
  89. package/src/core/plugin-dependencies.mjs +6 -1
  90. package/src/core/preflight-gate.mjs +77 -0
  91. package/src/core/preflight-receipt.mjs +123 -0
  92. package/src/core/runtime.mjs +0 -3
  93. package/src/core/surface.mjs +39 -2
  94. package/src/server/invoke-durability.mjs +39 -0
  95. package/src/server/invoke-write-child.mjs +25 -0
  96. package/src/server/routes/invoke.mjs +7 -0
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Fleet OpenWiki refresh.
3
+ *
4
+ * The generated `openwiki/` pages are a per-repository projection: they are
5
+ * written into <repo>/openwiki/ and describe that repository's code. They rot
6
+ * silently, because nothing in the repository is obliged to regenerate them —
7
+ * repositories have carried pages three months and 78-149 commits out of date
8
+ * while still looking authoritative.
9
+ *
10
+ * The driver belongs here rather than in every repository: the plugin is one of
11
+ * the bundled defaults served from the shared brain, so one implementation
12
+ * covers every registered project and every mesh node that resolves it. No host
13
+ * runtime is required — this reads the project registry and shells out to the
14
+ * `openwiki` CLI, which is the tool that owns page generation.
15
+ *
16
+ * Refreshing needs a model credential, so the key is read through the auth
17
+ * plugin (`auth secret get`). The bare `secret` form is a dead end in some
18
+ * releases; both are tried and the failure is explicit.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { spawn, spawnSync } from 'node:child_process';
25
+ import { loadOpenWikiConfig } from './config.mjs';
26
+
27
+ const PLUGIN_ROOT = fileURLToPath(new URL('..', import.meta.url));
28
+
29
+ /** Registry written by the core when a project is registered. */
30
+ const RETENTION_NOTE = 'pages are a projection, not canonical memory';
31
+ const DEFAULT_LOCK_MS = 6 * 60 * 60 * 1000;
32
+
33
+ export function registryPath(home = os.homedir()) {
34
+ return path.join(home, '.agent', 'skills', 'total-recall', 'config', 'project-registry.json');
35
+ }
36
+
37
+ /**
38
+ * Read the registered projects. The registry is the same source the updater
39
+ * uses to reach every consumer, so fleet scope is defined in exactly one place.
40
+ */
41
+ export function readProjects({ registry = registryPath() } = {}) {
42
+ let raw;
43
+ try {
44
+ raw = fs.readFileSync(registry, 'utf8');
45
+ } catch {
46
+ throw new Error(`Project registry not readable: ${registry}`);
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ } catch {
52
+ throw new Error(`Project registry is not valid JSON: ${registry}`);
53
+ }
54
+ if (!Array.isArray(parsed)) throw new Error('Project registry must be an array');
55
+ const projects = [];
56
+ for (const entry of parsed) {
57
+ if (!entry || typeof entry !== 'object') continue;
58
+ const root = entry.path;
59
+ if (typeof root !== 'string' || !path.isAbsolute(root)) continue;
60
+ projects.push({ name: String(entry.name || path.basename(root)), root });
61
+ }
62
+ // One project per root: the registry has historically held duplicates.
63
+ const seen = new Set();
64
+ return projects.filter((p) => {
65
+ const key = path.resolve(p.root);
66
+ if (seen.has(key)) return false;
67
+ seen.add(key);
68
+ return true;
69
+ });
70
+ }
71
+
72
+ /** Does this project carry generated pages at all? A repo with no openwiki/ is skipped, never created. */
73
+ export function hasWiki(root) {
74
+ const dir = path.join(root, 'openwiki');
75
+ const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
76
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return false;
77
+ return fs.readdirSync(dir).some((name) => name.endsWith('.md'));
78
+ }
79
+
80
+ /**
81
+ * How far behind is one project? Commits touching anything outside openwiki/
82
+ * since the recorded generation, which is the same measure the per-repo driver
83
+ * used. Uncommitted work is not counted, so an open edit does not read as drift.
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
+
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
+ }
95
+ const marker = path.join(root, 'openwiki', '.last-update.json');
96
+ let head = '';
97
+ const headRun = git('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
98
+ if (!headRun.error && headRun.status === 0) head = String(headRun.stdout || '').trim();
99
+
100
+ let generatedAt = null;
101
+ let generatedStatus = null;
102
+ try {
103
+ const parsed = JSON.parse(fs.readFileSync(marker, 'utf8'));
104
+ generatedAt = typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null;
105
+ generatedStatus = typeof parsed.status === 'string' ? parsed.status : null;
106
+ } catch { /* no marker: never generated, or unreadable */ }
107
+
108
+ // The page contents, so a refresh can be judged on what it actually rewrote.
109
+ // openwiki writes its marker when generation begins, so the marker alone
110
+ // cannot tell a completed refresh from one killed moments later.
111
+ let files = null;
112
+ try {
113
+ const dir = path.join(root, 'openwiki');
114
+ files = {};
115
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
116
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
117
+ files[entry.name] = fs.readFileSync(path.join(dir, entry.name), 'utf8');
118
+ }
119
+ } catch { files = null; }
120
+
121
+ const log = git('git', ['-C', root, 'log', '-1', '--format=%cI', '--', '.', ':!openwiki'], { encoding: 'utf8' });
122
+ const lastCodeChange = !log.error && log.status === 0 ? String(log.stdout || '').trim() || null : null;
123
+
124
+ let commitsSince = null;
125
+ if (generatedAt) {
126
+ const count = git('git', ['-C', root, 'rev-list', '--count', `--since=${generatedAt}`, 'HEAD', '--', '.', ':!openwiki'], { encoding: 'utf8' });
127
+ if (!count.error && count.status === 0) commitsSince = Number(String(count.stdout || '').trim());
128
+ }
129
+ const stale = generatedAt === null || (Number.isFinite(commitsSince) && commitsSince > 0);
130
+ return { root, head, generatedAt, generatedStatus, files, lastCodeChange, commitsSince, stale };
131
+ }
132
+
133
+ /**
134
+ * Resolve a model credential through the auth plugin.
135
+ *
136
+ * `total-recall secret get` is refused in releases where the bare name is
137
+ * blocked, and the refusal exits 0 while printing advice, so an empty value is
138
+ * the only symptom. Try the owning namespace, then the bare form for older
139
+ * CLIs, and report failure rather than refreshing without a key.
140
+ */
141
+ export function resolveKey({ provider = null, keys = null, run = spawnSync } = {}) {
142
+ const activeProvider = provider || (typeof loadOpenWikiConfig === 'function' ? loadOpenWikiConfig().provider : 'openrouter');
143
+ const candidateKeys = keys || (activeProvider === 'google'
144
+ ? ['GOOGLE_API_KEY', 'GEMINI_API_KEY']
145
+ : ['OPENROUTER_API_KEY', 'JSN_OPENROUTER_API_KEY']);
146
+
147
+ for (const form of [['auth', 'secret', 'get'], ['secret', 'get']]) {
148
+ for (const name of candidateKeys) {
149
+ const result = run('total-recall', [...form, name], { encoding: 'utf8', timeout: 90_000 });
150
+ if (result.error || result.status !== 0) continue;
151
+ const value = String(result.stdout || '').trim().split('\n').pop()?.trim();
152
+ if (value && !/^Credential commands belong/.test(value)) return { key: value, from: `${form.join(' ')} ${name}` };
153
+ }
154
+ }
155
+ return { key: null, from: null };
156
+ }
157
+
158
+ /**
159
+ * Refresh one project's pages.
160
+ *
161
+ * `openwiki --update` regenerates in place and needs a TTY: it is an Ink app,
162
+ * so a headless run must be detached from stdio or it dies on raw mode. The
163
+ * command is asserted to have rewritten the marker afterwards rather than
164
+ * trusting its exit code, because the failure mode this replaces was a refresh
165
+ * that reported success while changing nothing.
166
+ */
167
+ /**
168
+ * Locate the page generator.
169
+ *
170
+ * `openwiki` is not installed on PATH on most machines — it is fetched or run
171
+ * through npx — so assuming the bare name produced `spawnSync openwiki ENOENT`
172
+ * for every project that did have pages.
173
+ *
174
+ * npx is the last resort, not the fallback: `npx openwiki` re-resolves against
175
+ * the registry and a slow or unreachable network fails the whole refresh with
176
+ * `spawnSync npx ETIMEDOUT`. A previously fetched copy already sits in the npx
177
+ * cache, so use that before going to the network, and only reach for `--yes`
178
+ * when nothing cached exists.
179
+ */
180
+ export function resolveGenerator({ pluginRoot = null, run = spawnSync, env = process.env, home = os.homedir(), targetVersion = null } = {}) {
181
+ const root = pluginRoot !== null ? pluginRoot : PLUGIN_ROOT;
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
+ }
187
+ const candidate = path.join(root, 'node_modules', '.bin', 'openwiki');
188
+ if (fs.existsSync(candidate)) return { command: candidate, args: [], via: 'plugin local node_modules/.bin' };
189
+ }
190
+
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
+ }
196
+ const local = path.join(process.cwd(), 'node_modules', '.bin', 'openwiki');
197
+ if (fs.existsSync(local)) return { command: local, args: [], via: 'local node_modules/.bin' };
198
+ }
199
+
200
+ const onPath = run('which', ['openwiki'], { encoding: 'utf8' });
201
+ if (!onPath.error && onPath.status === 0) {
202
+ const found = String(onPath.stdout || '').trim().split('\n').pop();
203
+ if (found) return { command: found, args: [], via: 'PATH' };
204
+ }
205
+
206
+ // A cached npx install is a real binary and needs no network.
207
+ const cacheRoot = path.join(home, '.npm', '_npx');
208
+ try {
209
+ const entries = fs.readdirSync(cacheRoot, { withFileTypes: true })
210
+ .filter((e) => e.isDirectory())
211
+ .map((e) => path.join(cacheRoot, e.name, 'node_modules', '.bin', 'openwiki'))
212
+ .filter((candidate) => fs.existsSync(candidate))
213
+ .map((candidate) => ({ candidate, mtime: fs.statSync(candidate).mtimeMs }))
214
+ .sort((a, b) => b.mtime - a.mtime);
215
+ if (entries.length) return { command: entries[0].candidate, args: [], via: 'npx cache' };
216
+ } catch { /* no cache directory */ }
217
+
218
+ const npx = run('which', ['npx'], { encoding: 'utf8' });
219
+ if (!npx.error && npx.status === 0) {
220
+ const versionTag = targetVersion || '0.7.3';
221
+ return { command: 'npx', args: ['--yes', `openwiki@${versionTag}`], via: `npx openwiki@${versionTag} (network)` };
222
+ }
223
+ return { command: null, args: [], via: null };
224
+ }
225
+
226
+ /**
227
+ * Which pages did the run actually rewrite?
228
+ *
229
+ * Byte comparison against the previous content is the only honest measure: the
230
+ * marker advances when generation BEGINS, so it cannot distinguish a completed
231
+ * refresh from one killed seconds later.
232
+ */
233
+ export function pagesWritten(before, after) {
234
+ if (!before.files || !after.files) return 0;
235
+ let n = 0;
236
+ for (const [name, content] of Object.entries(after.files)) {
237
+ if (before.files[name] !== content) n += 1;
238
+ }
239
+ return n;
240
+ }
241
+
242
+ /** Is a generation run still in progress, per its own run state? */
243
+ export function stillGenerating(root) {
244
+ try {
245
+ const run = JSON.parse(fs.readFileSync(path.join(root, 'openwiki', '.run.json'), 'utf8'));
246
+ return typeof run.phase === 'string' && run.phase !== 'complete' && run.phase !== 'idle';
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ export function refreshProject(project, {
253
+ key, model = null, provider = null, binary = null, binaryArgs = [], timeoutMs = 30 * 60 * 1000, run = spawnSync, now = Date.now(), targetVersion = null, pluginRoot = PLUGIN_ROOT,
254
+ } = {}) {
255
+ if (!isGitRepo(project.root, { git: run })) {
256
+ return { ...project, action: 'skipped', reason: 'not a git repository' };
257
+ }
258
+ const before = staleness(project.root, { git: run });
259
+ const uninitialized = !hasWiki(project.root);
260
+
261
+ let command = binary, args = binaryArgs, via = binary ? 'explicit' : null;
262
+ if (!command) {
263
+ const resolved = resolveGenerator({ run, targetVersion, pluginRoot });
264
+ if (!resolved.command) {
265
+ return { ...project, action: 'failed', reason: 'no openwiki generator available (no local .bin, no PATH entry, no npx)', ...before };
266
+ }
267
+ ({ command, args, via } = resolved);
268
+ }
269
+
270
+ const config = typeof loadOpenWikiConfig === 'function' ? loadOpenWikiConfig() : { model: 'deepseek/deepseek-chat', provider: 'openrouter' };
271
+ const selectedModel = model || config.model;
272
+ const selectedProvider = provider || config.provider;
273
+
274
+ const modeFlag = uninitialized ? '--init' : '--update';
275
+ const cmdArgs = [...args, modeFlag, '-p'];
276
+ if (selectedModel) {
277
+ cmdArgs.push('--modelId', selectedModel);
278
+ }
279
+
280
+ if (uninitialized) {
281
+ const runFile = path.join(project.root, 'openwiki', '.run.json');
282
+ if (fs.existsSync(runFile)) {
283
+ try { fs.unlinkSync(runFile); } catch {}
284
+ }
285
+ }
286
+
287
+ const childEnv = { ...process.env };
288
+ if (selectedProvider === 'google') {
289
+ childEnv.GOOGLE_API_KEY = key;
290
+ childEnv.GEMINI_API_KEY = key;
291
+ childEnv.OPENWIKI_PROVIDER = 'gemini';
292
+ delete childEnv.OPENAI_API_KEY;
293
+ delete childEnv.OPENROUTER_API_KEY;
294
+ } else {
295
+ childEnv.OPENROUTER_API_KEY = key;
296
+ childEnv.OPENWIKI_PROVIDER = 'openrouter';
297
+ if (config.max_tokens) {
298
+ childEnv.OPENWIKI_OPENROUTER_MAX_TOKENS = String(config.max_tokens);
299
+ childEnv.OPENWIKI_MAX_OUTPUT_TOKENS = String(config.max_tokens);
300
+ }
301
+ delete childEnv.OPENAI_API_KEY;
302
+ }
303
+
304
+ const startedAt = now;
305
+ const result = run(command, cmdArgs, {
306
+ cwd: project.root,
307
+ env: childEnv,
308
+ stdio: ['ignore', 'pipe', 'pipe'],
309
+ encoding: 'utf8',
310
+ timeout: timeoutMs,
311
+ });
312
+
313
+ // OpenWiki generator may drop GitHub workflow files; our system strictly forbids GitHub Actions.
314
+ const workflowDir = path.join(project.root, '.github', 'workflows');
315
+ const workflowFile = path.join(workflowDir, 'openwiki-update.yml');
316
+ if (fs.existsSync(workflowFile)) {
317
+ try { fs.unlinkSync(workflowFile); } catch {}
318
+ }
319
+ if (fs.existsSync(workflowDir)) {
320
+ try {
321
+ const remaining = fs.readdirSync(workflowDir);
322
+ if (remaining.length === 0) fs.rmdirSync(workflowDir);
323
+ } catch {}
324
+ }
325
+
326
+ const after = staleness(project.root, { git: run });
327
+ const exitCode = result.error ? (result.error.code || 1) : result.status;
328
+ const advanced = after.generatedAt !== before.generatedAt || (uninitialized && hasWiki(project.root));
329
+ const wrote = pagesWritten(before, after) || (uninitialized && hasWiki(project.root) ? 1 : 0);
330
+
331
+ const outcome = after.generatedStatus === 'interrupted' || stillGenerating(project.root)
332
+ ? 'partial'
333
+ : (advanced ? (uninitialized ? 'initialized' : 'refreshed') : 'unchanged');
334
+
335
+ if (exitCode !== 0 && !wrote) {
336
+ return { ...project, action: 'failed', reason: result.error?.message || `exit ${exitCode}`, exitCode, ...after };
337
+ }
338
+ if (!advanced && !wrote) {
339
+ return { ...project, action: 'failed', reason: `command exited ${exitCode} and no page changed`, exitCode, ...after };
340
+ }
341
+ if (outcome === 'partial') {
342
+ return {
343
+ ...project,
344
+ action: 'partial',
345
+ reason: `${wrote} page(s) rewritten, but the run did not finish (the generator was cut off); it resumes on the next refresh`,
346
+ exitCode,
347
+ from: before.generatedAt,
348
+ to: after.generatedAt,
349
+ via,
350
+ ...after,
351
+ };
352
+ }
353
+ if (exitCode !== 0) {
354
+ return { ...project, action: 'failed', reason: result.error?.message || `exit ${exitCode}`, exitCode, ...after };
355
+ }
356
+ return { ...project, action: uninitialized ? 'initialized' : 'refreshed', from: before.generatedAt, to: after.generatedAt, durationMs: Date.now() - startedAt, via, ...after };
357
+ }
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
+
483
+ /** Is a refresh due for this project under the configured interval? */
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
+ }
488
+ const state = staleness(project.root, { git });
489
+ if (!hasWiki(project.root)) {
490
+ return { due: true, uninitialized: true, reason: 'uninitialized (missing openwiki/ pages)', ...state };
491
+ }
492
+ if (!state.stale) return { due: false, ...state };
493
+ if (state.generatedAt) {
494
+ const at = Date.parse(state.generatedAt);
495
+ if (Number.isFinite(at) && now - at < lockMs) return { due: false, reason: 'refreshed recently', ...state };
496
+ }
497
+ return { due: true, ...state };
498
+ }
499
+
500
+ export function describeRetention() {
501
+ return `OpenWiki ${RETENTION_NOTE}; refresh rewrites openwiki/ only and never vault content.`;
502
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * OpenWiki Evidence Search & Query Engine.
3
+ *
4
+ * Searches knowledge hubs across local workspace, all registered project vaults,
5
+ * or remote mesh nodes. Extracts evidence passages and linked concepts.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { discoverAllHubs, execOnMeshNode } from './mesh-vaults.mjs';
10
+
11
+ /**
12
+ * Searches for query terms across markdown files.
13
+ */
14
+ export async function searchWikiHubs(query, {
15
+ allVaults = false,
16
+ node = null,
17
+ limit = 20,
18
+ cwd = process.cwd(),
19
+ } = {}) {
20
+ if (!query || typeof query !== 'string' || !query.trim()) {
21
+ throw new Error('Search query must be a non-empty string');
22
+ }
23
+
24
+ // If remote mesh node specified, dispatch via mesh
25
+ if (node && node !== 'local' && node !== 'self') {
26
+ const escapedQuery = query.replace(/"/g, '\\"');
27
+ const meshRes = execOnMeshNode(
28
+ node,
29
+ `total-recall openwiki search "${escapedQuery}" ${allVaults ? '--all-vaults' : ''} --json`
30
+ );
31
+ if (meshRes.ok) {
32
+ try {
33
+ return JSON.parse(meshRes.stdout);
34
+ } catch {
35
+ throw new Error(`Invalid JSON from mesh node ${node}: ${meshRes.stdout}`);
36
+ }
37
+ } else {
38
+ throw new Error(`Mesh search failed on node ${node}: ${meshRes.stderr || meshRes.error}`);
39
+ }
40
+ }
41
+
42
+ const hubsResult = await discoverAllHubs({ allVaults, cwd });
43
+ const rawTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
44
+ const terms = rawTerms.map((t) => t.replace(/[^\w-]/g, ''));
45
+
46
+ const matches = [];
47
+
48
+ for (const hub of hubsResult.hubs) {
49
+ let content;
50
+ try {
51
+ content = fs.readFileSync(hub.path, 'utf8');
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ const lines = content.split('\n');
57
+ let currentHeading = hub.title;
58
+
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const line = lines[i];
61
+ const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
62
+ if (headingMatch) {
63
+ currentHeading = headingMatch[1].trim();
64
+ }
65
+
66
+ const lowerLine = line.toLowerCase();
67
+ let termHits = 0;
68
+ for (const term of terms) {
69
+ if (lowerLine.includes(term)) termHits++;
70
+ }
71
+
72
+ if (termHits > 0) {
73
+ // Build surrounding snippet (1 line before, matched line, 1 line after)
74
+ const start = Math.max(0, i - 1);
75
+ const end = Math.min(lines.length, i + 2);
76
+ const snippetLines = lines.slice(start, end).map((l) => l.trim()).filter(Boolean);
77
+ const snippet = snippetLines.join(' ');
78
+
79
+ // Score: exact full phrase match bonus, heading bonus, multiple term bonus
80
+ let score = termHits * 10;
81
+ if (lowerLine.includes(query.toLowerCase())) score += 30;
82
+ if (headingMatch) score += 20;
83
+
84
+ matches.push({
85
+ title: hub.title,
86
+ section: currentHeading,
87
+ fileName: hub.fileName,
88
+ repoName: hub.repoName,
89
+ path: hub.path,
90
+ line: i + 1,
91
+ snippet: snippet.length > 280 ? snippet.slice(0, 277) + '...' : snippet,
92
+ score,
93
+ });
94
+
95
+ // Skip forward so overlapping lines don't spam identical snippets
96
+ i += 1;
97
+ }
98
+ }
99
+ }
100
+
101
+ // Sort descending by relevance score
102
+ matches.sort((a, b) => b.score - a.score);
103
+ const truncated = matches.slice(0, limit);
104
+
105
+ return {
106
+ ok: true,
107
+ query,
108
+ scope: hubsResult.scope,
109
+ totalHubsScanned: hubsResult.count,
110
+ count: truncated.length,
111
+ totalHits: matches.length,
112
+ results: truncated,
113
+ };
114
+ }