total-recall-brain 3.46.2 → 3.47.1

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 (89) hide show
  1. package/bin/total-recall.mjs +5 -0
  2. package/frontend/dist/assets/index-CpsWeV4X.css +1 -0
  3. package/frontend/dist/assets/{index-D1goIPjC.js → index-poecbHSl.js} +100 -100
  4. package/frontend/dist/brand/total-recall-lockup-transparent.png +0 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/sw.js +1 -1
  7. package/frontend/src/App.tsx +15 -81
  8. package/frontend/src/components/DashboardOverview.tsx +164 -0
  9. package/frontend/src/components/brand/BrandMark.tsx +8 -6
  10. package/frontend/src/components/plugins/PluginWorkspace.tsx +112 -57
  11. package/frontend/src/components/workspace/PluginHome.tsx +3 -3
  12. package/frontend/src/index.css +43 -11
  13. package/package.json +1 -1
  14. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +4 -4
  15. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -2
  16. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +30 -5
  17. package/scaffold/.agent/skills/total-recall/plugins/auth/skills/auth/SKILL.md +35 -0
  18. package/scaffold/.agent/skills/total-recall/plugins/auth/ui/home.js +494 -79
  19. package/scaffold/.agent/skills/total-recall/plugins/automations/package-lock.json +4 -4
  20. package/scaffold/.agent/skills/total-recall/plugins/automations/package.json +3 -2
  21. package/scaffold/.agent/skills/total-recall/plugins/automations/plugin.json +33 -8
  22. package/scaffold/.agent/skills/total-recall/plugins/automations/skills/automations/SKILL.md +29 -0
  23. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/chunks/dream-consolidation.md +17 -0
  24. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  25. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -2
  26. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +31 -4
  27. package/scaffold/.agent/skills/total-recall/plugins/code-quality/skills/code-quality/SKILL.md +27 -0
  28. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  29. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
  30. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +17 -3
  31. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  32. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -2
  33. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +24 -4
  34. package/scaffold/.agent/skills/total-recall/plugins/embeddings/skills/embeddings/SKILL.md +25 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/git/package-lock.json +6 -6
  36. package/scaffold/.agent/skills/total-recall/plugins/git/package.json +3 -2
  37. package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +300 -58
  38. package/scaffold/.agent/skills/total-recall/plugins/git/skills/git/SKILL.md +36 -0
  39. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +5 -33
  40. package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-fleet-ops.md +16 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-publish.md +16 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4 -4
  43. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -2
  44. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +47 -14
  45. package/scaffold/.agent/skills/total-recall/plugins/mesh/skills/mesh/SKILL.md +36 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +6 -3
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +1 -1
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +5 -2
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +4 -2
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +28 -9
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +7 -1
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-selection.mjs +59 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +3 -3
  54. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +9 -3
  55. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +50 -6
  56. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/chunks/session-controls.md +16 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +175 -17
  58. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  59. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -2
  60. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +242 -31
  61. package/scaffold/.agent/skills/total-recall/plugins/openwiki/skills/openwiki/SKILL.md +32 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +41 -15
  63. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +29 -22
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +20 -33
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +42 -49
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/markdown-links.mjs +112 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +3 -2
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +219 -65
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +2 -1
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +21 -3
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/panel.js +51 -2
  72. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  73. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
  74. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +22 -5
  75. package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +3 -2
  76. package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +169 -37
  77. package/scaffold/.agent/skills/total-recall/plugins/ssss/skills/ssss/SKILL.md +30 -0
  78. package/src/cli/context.mjs +6 -2
  79. package/src/cli/plugin/create.mjs +2 -1
  80. package/src/cli/plugin/monetization.mjs +66 -32
  81. package/src/cli/plugin/search.mjs +25 -3
  82. package/src/cli/preflight.mjs +40 -6
  83. package/src/core/context-compactor.mjs +2 -0
  84. package/src/core/gate-mode.mjs +13 -24
  85. package/src/core/plugin-distribution.mjs +35 -1
  86. package/src/core/preflight-gate.mjs +28 -8
  87. package/src/core/preflight-receipt.mjs +11 -0
  88. package/src/server/routes/vault.mjs +1 -0
  89. package/frontend/dist/assets/index-BRYE1jmv.css +0 -1
@@ -36,6 +36,120 @@ function readJson(file) {
36
36
  try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
37
37
  }
38
38
 
39
+ /**
40
+ * Paths outside every wiki. Total Recall rewrites them in every repository on each
41
+ * release (version pins in lockfiles, compiled agent instructions, copied skills), so
42
+ * counting them as changes paid a model to re-check pages after every release.
43
+ * `.openwikiignore` syntax: a leading "/" anchors at the repository root, a trailing
44
+ * "/" names a directory, a bare name matches at any depth.
45
+ */
46
+ export const DEFAULT_IGNORES = Object.freeze([
47
+ 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
48
+ '/CLAUDE.md', '/AGENTS.md', '/GEMINI.md', '/INSTRUCTIONS.md',
49
+ '/.cursorrules', '/.windsurfrules', '/.clinerules',
50
+ '/.cursor/rules/total-recall.mdc', '/.github/copilot-instructions.md', '/.agents/rules/AGENTS.md',
51
+ '/.agent/skills/total-recall/', '/.agent/skills/ssss/', '/.claude/skills/total-recall/', '/.claude/skills/ssss/',
52
+ ]);
53
+ const IGNORE_BLOCK_START = '# >>> total-recall openwiki defaults (managed; add your own rules outside this block)';
54
+ const IGNORE_BLOCK_END = '# <<< total-recall openwiki defaults';
55
+
56
+ /** Does a DEFAULT_IGNORES pattern cover this repository-relative file path? */
57
+ export function ignoreMatches(pattern, file) {
58
+ if (pattern.startsWith('/')) {
59
+ const body = pattern.slice(1);
60
+ return body.endsWith('/') ? file.startsWith(body) : file === body;
61
+ }
62
+ return file === pattern || file.endsWith(`/${pattern}`);
63
+ }
64
+
65
+ /**
66
+ * Repository paths the existing wiki cites as evidence (claims and page sources).
67
+ * OpenWiki refuses a run whose evidence path is ignored ("Evidence path is excluded
68
+ * by .openwikiignore"), so a cited path is never ignored.
69
+ */
70
+ export function citedPaths(root) {
71
+ const cited = new Set();
72
+ const walk = (dir) => {
73
+ let entries;
74
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
75
+ for (const entry of entries) {
76
+ const full = path.join(dir, entry.name);
77
+ if (entry.isDirectory()) walk(full);
78
+ else if (/\.(json|md)$/.test(entry.name)) {
79
+ let text;
80
+ try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
81
+ for (const match of text.matchAll(/repo:\/\/([^\s"'#)\]]+)/g)) cited.add(match[1].replace(/^\/+/, ''));
82
+ }
83
+ }
84
+ };
85
+ walk(path.join(root, 'openwiki'));
86
+ return cited;
87
+ }
88
+
89
+ /** The default ignores that apply to this repository: every one no wiki evidence cites. */
90
+ export function effectiveIgnores(root, cited = citedPaths(root)) {
91
+ return DEFAULT_IGNORES.filter((pattern) => ![...cited].some((file) => ignoreMatches(pattern, file)));
92
+ }
93
+
94
+ /** Git pathspecs that leave the ignored paths out of a commit count. */
95
+ export function ignorePathspecs(patterns) {
96
+ return patterns.map((pattern) => (pattern.startsWith('/')
97
+ ? `:(exclude)${pattern.slice(1).replace(/\/$/, '')}`
98
+ : `:(exclude,glob)**/${pattern}`));
99
+ }
100
+
101
+ /**
102
+ * Keep the managed default block in the repository's `.openwikiignore`, leaving
103
+ * every other line as the owner wrote it. Writes only when the block changes.
104
+ */
105
+ export function ensureDefaultIgnores(root, patterns = effectiveIgnores(root)) {
106
+ const file = path.join(root, '.openwikiignore');
107
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
108
+ const start = current.indexOf(IGNORE_BLOCK_START);
109
+ const end = current.indexOf(IGNORE_BLOCK_END);
110
+ const outside = start >= 0 && end > start ? current.slice(0, start) + current.slice(end + IGNORE_BLOCK_END.length) : current;
111
+ const block = [
112
+ IGNORE_BLOCK_START,
113
+ '# Rewritten in every repository by each Total Recall release: lockfiles, compiled agent',
114
+ '# instructions and copied skills. A path the existing wiki cites is left out of this list.',
115
+ ...patterns,
116
+ IGNORE_BLOCK_END,
117
+ ].join('\n');
118
+ const rest = outside.replace(/^\n+|\n+$/g, '');
119
+ const next = `${rest ? `${rest}\n\n` : ''}${block}\n`;
120
+ if (next === current) return { file, written: false, patterns };
121
+ fs.writeFileSync(file, next);
122
+ return { file, written: true, patterns };
123
+ }
124
+
125
+ const VERSION_LINE = /^[+-]\s*"([^"]+)"\s*:\s*"([^"]*)"\s*,?\s*$/;
126
+ const VERSION_SPEC = /^(?:npm:(?:@[^/\s]+\/)?[^@\s]+@)?[~^<>=\s]*v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?$/;
127
+
128
+ /**
129
+ * How many commits in `git log -p -U0 --format=%x1eCOMMIT %H` output changed
130
+ * nothing but version strings in package.json files. A Total Recall release pins
131
+ * its new version into every repository this way; that is not a change to the code
132
+ * the wiki documents. Any other edit to package.json (a script, a new dependency)
133
+ * still counts.
134
+ */
135
+ export function versionOnlyCommits(logPatch) {
136
+ let total = 0;
137
+ for (const chunk of logPatch.split('\x1eCOMMIT ').slice(1)) {
138
+ const files = [...chunk.matchAll(/^diff --git a\/(\S+) b\/(\S+)$/gm)].map((m) => m[2]);
139
+ if (!files.length || files.some((file) => path.posix.basename(file) !== 'package.json')) continue;
140
+ const changed = chunk.split('\n').filter((line) => /^[+-]/.test(line) && !/^(\+\+\+|---) /.test(line));
141
+ const removed = [], added = [];
142
+ let versionsOnly = changed.length > 0;
143
+ for (const line of changed) {
144
+ const match = VERSION_LINE.exec(line);
145
+ if (!match || !VERSION_SPEC.test(match[2].trim())) { versionsOnly = false; break; }
146
+ (line[0] === '-' ? removed : added).push(match[1]);
147
+ }
148
+ if (versionsOnly && removed.sort().join('\n') === added.sort().join('\n')) total += 1;
149
+ }
150
+ return total;
151
+ }
152
+
39
153
  /** An unfinished init must resume as init, even after it has written pages. */
40
154
  export function generationMode(root) {
41
155
  const state = readJson(path.join(root, 'openwiki', '.run.json'));
@@ -51,7 +165,7 @@ export function registryPath(home = os.homedir()) {
51
165
  * Read the registered projects. The registry is the same source the updater
52
166
  * uses to reach every consumer, so fleet scope is defined in exactly one place.
53
167
  */
54
- export function readProjects({ registry = registryPath() } = {}) {
168
+ export function readProjects({ registry = registryPath(), exclude = null } = {}) {
55
169
  let raw;
56
170
  try {
57
171
  raw = fs.readFileSync(registry, 'utf8');
@@ -74,14 +188,18 @@ export function readProjects({ registry = registryPath() } = {}) {
74
188
  }
75
189
  // One project per root: the registry has historically held duplicates.
76
190
  const seen = new Set();
191
+ const excluded = new Set((exclude ?? loadOpenWikiConfig().exclude ?? []).map((root) => path.resolve(root)));
77
192
  return projects.filter((p) => {
78
193
  const key = path.resolve(p.root);
79
194
  if (seen.has(key)) return false;
80
195
  seen.add(key);
81
196
  return true;
82
- });
197
+ }).map((p) => ({ ...p, excluded: excluded.has(path.resolve(p.root)) }));
83
198
  }
84
199
 
200
+ export const EXCLUDED = "excluded from OpenWiki (total-recall openwiki exclude --remove <name> to include it)";
201
+ export const NO_WIKI = "no finished wiki; the nightly refresh only updates existing wikis. Create one with: total-recall openwiki generate --repo <name>";
202
+
85
203
  /** Does this project carry generated Markdown pages? */
86
204
  export function hasWiki(root) {
87
205
  const dir = path.join(root, 'openwiki');
@@ -139,15 +257,22 @@ export function staleness(root, { git = gitRun } = {}) {
139
257
  }
140
258
  } catch { files = null; }
141
259
 
142
- const log = git('git', ['-C', root, 'log', '-1', '--format=%cI', '--', '.', ':!openwiki'], { encoding: 'utf8' });
260
+ // A commit that touches only the ignored paths (a release's version pins and
261
+ // compiled instructions) is not a change to document.
262
+ const scope = ['--', '.', ':!openwiki', ...ignorePathspecs(effectiveIgnores(root))];
263
+ const log = git('git', ['-C', root, 'log', '-1', '--format=%cI', ...scope], { encoding: 'utf8' });
143
264
  const lastCodeChange = !log.error && log.status === 0 ? String(log.stdout || '').trim() || null : null;
144
265
 
145
266
  let commitsSince = null;
146
267
  if (generationCommit || generatedAt) {
147
268
  const range = generationCommit ? [`${generationCommit}..HEAD`] : [`--since=${generatedAt}`, 'HEAD'];
148
- const count = git('git', ['-C', root, 'rev-list', '--count', ...range, '--', '.', ':!openwiki'], { encoding: 'utf8' });
269
+ const count = git('git', ['-C', root, 'rev-list', '--count', ...range, ...scope], { encoding: 'utf8' });
149
270
  const value = String(count.stdout || '').trim();
150
271
  if (!count.error && count.status === 0 && /^\d+$/.test(value)) commitsSince = Number(value);
272
+ if (commitsSince > 0) {
273
+ const patches = git('git', ['-C', root, 'log', '-p', '-U0', '--no-color', '--no-ext-diff', '--format=%x1eCOMMIT %H', ...range, ...scope], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
274
+ if (!patches.error && patches.status === 0) commitsSince = Math.max(0, commitsSince - versionOnlyCommits(String(patches.stdout || '')));
275
+ }
151
276
  }
152
277
  const incomplete = generatedStatus === 'interrupted' || stillGenerating(root);
153
278
  const stale = incomplete || generatedAt === null || commitsSince === null || commitsSince > 0;
@@ -287,12 +412,44 @@ function writeReceipt(root, name, value) {
287
412
  }
288
413
  }
289
414
 
290
- function redact(value, key) {
415
+ export const CREDIT_REFUSAL = /\b402\b|insufficient credits|requires more credits|credit limit/i;
416
+
417
+ export function redact(value, key) {
291
418
  let text = String(value || '');
292
419
  if (key) text = text.split(key).join('[REDACTED]');
293
420
  return text.replace(/\bBearer\s+\S+/gi, 'Bearer [REDACTED]').replace(/\bsk-[\w-]+/g, '[REDACTED]');
294
421
  }
295
422
 
423
+ // The model is the operator's choice; models and prices change too often for a default here.
424
+ export const NO_MODEL = 'No OpenWiki model is configured. Check current OpenRouter models and prices, then run: total-recall openwiki config set model <model-id>';
425
+
426
+ export function buildGeneratorInvocation({ baseArgs = [], mode = 'update', config = {}, key = '' } = {}) {
427
+ const selectedModel = String(config.model || '').trim();
428
+ if (!selectedModel) throw new Error(NO_MODEL);
429
+ const selectedProvider = config.provider || 'openrouter';
430
+ const cmdArgs = [...baseArgs, `--${mode}`, '-p', '--modelId', selectedModel];
431
+ const childEnv = { ...process.env };
432
+ if (selectedProvider === 'google') {
433
+ childEnv.GOOGLE_API_KEY = key;
434
+ childEnv.GEMINI_API_KEY = key;
435
+ childEnv.OPENWIKI_PROVIDER = 'gemini';
436
+ delete childEnv.OPENAI_API_KEY;
437
+ delete childEnv.OPENROUTER_API_KEY;
438
+ } else {
439
+ childEnv.OPENROUTER_API_KEY = key;
440
+ childEnv.OPENWIKI_PROVIDER = 'openrouter';
441
+ if (config.max_tokens) {
442
+ childEnv.OPENWIKI_OPENROUTER_MAX_TOKENS = String(config.max_tokens);
443
+ childEnv.OPENWIKI_MAX_OUTPUT_TOKENS = String(config.max_tokens);
444
+ }
445
+ delete childEnv.OPENAI_API_KEY;
446
+ }
447
+ if (config.timeout_ms) {
448
+ childEnv.OPENWIKI_TIMEOUT = String(config.timeout_ms);
449
+ }
450
+ return { cmdArgs, childEnv, selectedModel, selectedProvider };
451
+ }
452
+
296
453
  function finishRefresh(project, { before, result, git, key, mode, startedAt, via, clock }) {
297
454
  const after = staleness(project.root, { git });
298
455
  const exitCode = result.error ? (result.error.code || 1) : result.signal ? 1 : result.status;
@@ -363,34 +520,17 @@ export function refreshProject(project, {
363
520
  ({ command, args, via } = resolved);
364
521
  }
365
522
 
366
- const config = typeof loadOpenWikiConfig === 'function' ? loadOpenWikiConfig() : { model: 'deepseek/deepseek-chat', provider: 'openrouter' };
367
- const selectedModel = model || config.model;
368
- const selectedProvider = provider || config.provider;
369
-
370
- const modeFlag = `--${mode}`;
371
- const cmdArgs = [...args, modeFlag, '-p'];
372
- if (selectedModel) {
373
- cmdArgs.push('--modelId', selectedModel);
374
- }
375
-
376
- const childEnv = { ...process.env };
377
- if (selectedProvider === 'google') {
378
- childEnv.GOOGLE_API_KEY = key;
379
- childEnv.GEMINI_API_KEY = key;
380
- childEnv.OPENWIKI_PROVIDER = 'gemini';
381
- delete childEnv.OPENAI_API_KEY;
382
- delete childEnv.OPENROUTER_API_KEY;
383
- } else {
384
- childEnv.OPENROUTER_API_KEY = key;
385
- childEnv.OPENWIKI_PROVIDER = 'openrouter';
386
- if (config.max_tokens) {
387
- childEnv.OPENWIKI_OPENROUTER_MAX_TOKENS = String(config.max_tokens);
388
- childEnv.OPENWIKI_MAX_OUTPUT_TOKENS = String(config.max_tokens);
389
- }
390
- delete childEnv.OPENAI_API_KEY;
391
- }
523
+ const config = loadOpenWikiConfig();
524
+ const { cmdArgs, childEnv } = buildGeneratorInvocation({
525
+ baseArgs: args,
526
+ mode,
527
+ config: { ...config, model: model || config.model, provider: provider || config.provider },
528
+ key,
529
+ });
392
530
 
531
+ ensureDefaultIgnores(project.root);
393
532
  const startedAt = now;
533
+ writeReceipt(project.root, '.refresh-started.json', { startedAt: new Date(startedAt).toISOString(), sourceCommit: before.head || null });
394
534
  const result = run(command, cmdArgs, {
395
535
  cwd: project.root,
396
536
  env: childEnv,
@@ -421,34 +561,17 @@ export async function refreshProjectAsync(project, {
421
561
  ({ command, args, via } = resolved);
422
562
  }
423
563
 
424
- const config = typeof loadOpenWikiConfig === 'function' ? loadOpenWikiConfig() : { model: 'deepseek/deepseek-chat', provider: 'openrouter' };
425
- const selectedModel = model || config.model;
426
- const selectedProvider = provider || config.provider;
427
-
428
- const modeFlag = `--${mode}`;
429
- const cmdArgs = [...args, modeFlag, '-p'];
430
- if (selectedModel) {
431
- cmdArgs.push('--modelId', selectedModel);
432
- }
433
-
434
- const childEnv = { ...process.env };
435
- if (selectedProvider === 'google') {
436
- childEnv.GOOGLE_API_KEY = key;
437
- childEnv.GEMINI_API_KEY = key;
438
- childEnv.OPENWIKI_PROVIDER = 'gemini';
439
- delete childEnv.OPENAI_API_KEY;
440
- delete childEnv.OPENROUTER_API_KEY;
441
- } else {
442
- childEnv.OPENROUTER_API_KEY = key;
443
- childEnv.OPENWIKI_PROVIDER = 'openrouter';
444
- if (config.max_tokens) {
445
- childEnv.OPENWIKI_OPENROUTER_MAX_TOKENS = String(config.max_tokens);
446
- childEnv.OPENWIKI_MAX_OUTPUT_TOKENS = String(config.max_tokens);
447
- }
448
- delete childEnv.OPENAI_API_KEY;
449
- }
564
+ const config = loadOpenWikiConfig();
565
+ const { cmdArgs, childEnv } = buildGeneratorInvocation({
566
+ baseArgs: args,
567
+ mode,
568
+ config: { ...config, model: model || config.model, provider: provider || config.provider },
569
+ key,
570
+ });
450
571
 
572
+ ensureDefaultIgnores(project.root);
451
573
  const startedAt = now;
574
+ writeReceipt(project.root, '.refresh-started.json', { startedAt: new Date(startedAt).toISOString(), sourceCommit: before.head || null });
452
575
  const result = await new Promise((resolve) => {
453
576
  const proc = spawnProcess(command, cmdArgs, {
454
577
  cwd: project.root,
@@ -465,22 +588,53 @@ export async function refreshProjectAsync(project, {
465
588
  return finishRefresh(project, { before, result, git, key, mode, startedAt, via, clock });
466
589
  }
467
590
 
468
- /** Is a refresh due for this project under the configured interval? */
591
+ /**
592
+ * Start time (ms) of the latest generation attempt, or NaN when none is recorded.
593
+ * `.refresh-started.json` is written before the generator runs, so an attempt
594
+ * the task runner killed still counts; the other two are written by the
595
+ * generator when it begins and by the driver when an attempt ends.
596
+ */
597
+ export function lastAttemptStart(root, generatedAt = null) {
598
+ const times = [
599
+ readJson(path.join(root, 'openwiki', '.refresh-started.json'))?.startedAt,
600
+ readJson(path.join(root, 'openwiki', '.refresh-attempt.json'))?.startedAt,
601
+ generatedAt,
602
+ ].map((value) => (typeof value === 'string' ? Date.parse(value) : NaN)).filter(Number.isFinite);
603
+ return times.length ? Math.max(...times) : NaN;
604
+ }
605
+
606
+ /**
607
+ * Is a refresh due for this project under the configured interval?
608
+ *
609
+ * Every attempt, including a retry of an interrupted or failed one, waits the
610
+ * full interval after the previous attempt began. Retrying an interrupted run
611
+ * on every scheduler slot spent about $10 a day on 2026-11-12/13: the task
612
+ * runner killed the same generations every 30 minutes and they never finished.
613
+ */
469
614
  export function isDue(project, { lockMs = DEFAULT_LOCK_MS, now = Date.now(), git = gitRun, force = false } = {}) {
615
+ if (project.excluded) return { due: false, excluded: true, reason: EXCLUDED };
470
616
  if (!isGitRepo(project.root, { git })) {
471
617
  return { due: false, skipped: true, reason: 'not a git repository' };
472
618
  }
473
619
  const state = staleness(project.root, { git });
474
- if (force) return { due: true, uninitialized: !hasWiki(project.root), ...state };
475
- if (!hasWiki(project.root)) {
476
- return { due: true, uninitialized: true, reason: 'uninitialized (missing openwiki/ pages)', ...state };
620
+ // Creating a wiki is the most expensive run there is, and a registered
621
+ // repository may be someone else's code. Only an explicit `openwiki generate`
622
+ // creates one or finishes an interrupted creation; refresh, --force included,
623
+ // only updates finished wikis.
624
+ if (generationMode(project.root) === 'init') {
625
+ return { due: false, uninitialized: true, ...state, reason: NO_WIKI };
477
626
  }
627
+ if (force) return { due: true, uninitialized: false, ...state };
628
+ const last = lastAttemptStart(project.root, state.generatedAt);
629
+ const retryAt = Number.isFinite(last) && now - last < lockMs ? new Date(last + lockMs).toISOString() : null;
478
630
  if (!state.stale) return { due: false, ...state };
479
- if (state.generatedStatus === 'interrupted' || stillGenerating(project.root)) return { due: true, reason: 'resume incomplete generation', ...state };
480
- if (state.generatedAt) {
481
- const at = Date.parse(state.generatedAt);
482
- if (Number.isFinite(at) && now - at < lockMs) return { due: false, reason: 'refreshed recently', ...state };
631
+ const incomplete = state.generatedStatus === 'interrupted' || stillGenerating(project.root);
632
+ if (retryAt) {
633
+ return incomplete
634
+ ? { due: false, held: true, reason: `incomplete generation attempted recently; resumes after ${retryAt}`, ...state }
635
+ : { due: false, reason: 'refreshed recently', ...state };
483
636
  }
637
+ if (incomplete) return { due: true, reason: 'resume incomplete generation', ...state };
484
638
  return { due: true, ...state };
485
639
  }
486
640
 
@@ -29,6 +29,7 @@ export async function searchWikiHubs(query, {
29
29
  limit = 20,
30
30
  cwd = process.cwd(),
31
31
  exactTerms = false,
32
+ nested = true,
32
33
  } = {}) {
33
34
  if (!query || typeof query !== 'string' || !query.trim()) {
34
35
  throw new Error('Search query must be a non-empty string');
@@ -50,7 +51,7 @@ export async function searchWikiHubs(query, {
50
51
  }
51
52
  }
52
53
 
53
- const hubsResult = await discoverAllHubs({ allVaults, cwd, nested: exactTerms });
54
+ const hubsResult = await discoverAllHubs({ allVaults, cwd, nested });
54
55
  const rawTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
55
56
  const terms = exactTerms ? wikiTaskTerms(query) : rawTerms.map(term => term.replace(/[^\w-]/g, '')).filter(Boolean);
56
57
 
@@ -10,7 +10,7 @@ import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { compareSemanticVersions, formatPropagationReceipt } from '../capability/index.mjs';
13
- import { hasWiki, readProjects, refreshProject, resolveKey } from './refresh.mjs';
13
+ import { CREDIT_REFUSAL, generationMode, readProjects, refreshProject, resolveKey } from './refresh.mjs';
14
14
 
15
15
  export const UPSTREAM_NPM_URL = 'https://registry.npmjs.org/openwiki/latest';
16
16
  export const UPSTREAM_GITHUB_URL = 'https://api.github.com/repos/langchain-ai/openwiki/releases/latest';
@@ -338,8 +338,9 @@ export async function propagateUpdates({
338
338
  }
339
339
  }
340
340
 
341
- // Filter to repositories with existing openwiki directories
342
- const eligible = projects.filter(p => hasWiki(p.root));
341
+ // Only finished wikis outside the exclude list: propagation updates pages,
342
+ // it never creates a wiki or documents an excluded clone, fork or backup.
343
+ const eligible = projects.filter(p => !p.excluded && generationMode(p.root) === 'update');
343
344
  if (eligible.length === 0) {
344
345
  return {
345
346
  ok: true,
@@ -378,8 +379,19 @@ export async function propagateUpdates({
378
379
  return { ok: false, error: error.message, eligibleCount: eligible.length, updatedCount: 0, reports: [] };
379
380
  }
380
381
  const reports = [];
382
+ let creditStopReason = null;
381
383
 
382
384
  for (const project of eligible) {
385
+ if (creditStopReason) {
386
+ reports.push({
387
+ name: project.name,
388
+ root: project.root,
389
+ action: 'deferred',
390
+ reason: creditStopReason,
391
+ generatorVersion: targetVersion
392
+ });
393
+ continue;
394
+ }
383
395
  try {
384
396
  const outcome = await refresher(project, {
385
397
  key,
@@ -392,6 +404,9 @@ export async function propagateUpdates({
392
404
  credential: from,
393
405
  generatorVersion: targetVersion
394
406
  });
407
+ if (CREDIT_REFUSAL.test(String(outcome?.reason || ''))) {
408
+ creditStopReason = `deferred: provider refused ${project.name} for credit; add credit, then propagate again`;
409
+ }
395
410
  } catch (err) {
396
411
  reports.push({
397
412
  name: project.name,
@@ -400,6 +415,9 @@ export async function propagateUpdates({
400
415
  reason: err.message,
401
416
  generatorVersion: targetVersion
402
417
  });
418
+ if (CREDIT_REFUSAL.test(String(err?.message || ''))) {
419
+ creditStopReason = `deferred: provider refused ${project.name} for credit; add credit, then propagate again`;
420
+ }
403
421
  }
404
422
  }
405
423
 
@@ -54,7 +54,8 @@ template.innerHTML = `
54
54
  .grid { display: grid; gap: var(--spacing-lg, 1.5rem); grid-template-columns: repeat(2, minmax(0, 1fr)); }
55
55
  ul { list-style: none; padding: 0; margin: 0; }
56
56
  li { overflow-wrap: anywhere; }
57
- .index li, .links li { border-bottom: 1px solid var(--color-outline, #cbb497); padding-block: 0.5rem; }
57
+ .index li, .links li, .issues li { border-bottom: 1px solid var(--color-outline, #cbb497); padding-block: 0.5rem; }
58
+ .health { margin-block-start: var(--spacing-lg, 1.5rem); }
58
59
  .index small { display: block; }
59
60
  small, code { font: 0.8rem/1.5 var(--typography-source-font-family, ui-monospace, monospace); }
60
61
  .results li {
@@ -106,6 +107,14 @@ template.innerHTML = `
106
107
  <ul id="links" class="links" aria-label="Documentation links"></ul>
107
108
  </section>
108
109
  </div>
110
+ <section class="health" aria-labelledby="health">
111
+ <div class="graph-tools">
112
+ <h3 id="health">Link health</h3>
113
+ <button type="button" id="lint">Check health</button>
114
+ </div>
115
+ <p id="lint-state" class="state" role="status">Audit broken links, missing titles, and orphaned pages.</p>
116
+ <ul id="issues" class="issues" aria-label="Documentation health issues"></ul>
117
+ </section>
109
118
  </section>
110
119
  `;
111
120
 
@@ -124,13 +133,16 @@ class OpenWikiPanel extends HTMLElement {
124
133
  this.attachShadow({ mode: 'open' }).append(template.content.cloneNode(true));
125
134
  this.#field('reload').addEventListener('click', () => void this.load());
126
135
  this.#field('graph').addEventListener('click', () => void this.#graph());
136
+ this.#field('lint').addEventListener('click', () => void this.#lint());
127
137
  this.#field('search').addEventListener('click', () => void this.#search());
128
138
  this.#field('scope').addEventListener('change', () => {
129
139
  this.#field('results').replaceChildren();
130
140
  this.#field('links').replaceChildren();
141
+ this.#field('issues').replaceChildren();
131
142
  const label = this.#scope() === 'all-vaults' ? 'registered wikis' : 'this workspace';
132
143
  this.#state('search-state', `Search headings and passages in ${label}.`);
133
144
  this.#state('graph-state', `Trace links between pages in ${label}.`);
145
+ this.#state('lint-state', `Audit broken links, missing titles, and orphaned pages in ${label}.`);
134
146
  });
135
147
  this.#field('query').addEventListener('keydown', event => {
136
148
  if (event.key !== 'Enter' || event.isComposing) return;
@@ -269,11 +281,14 @@ class OpenWikiPanel extends HTMLElement {
269
281
  list.append(row);
270
282
  }
271
283
  const { nodes, edges: total, orphans } = result.counts;
284
+ const orphanNames = Array.isArray(result.orphans) && result.orphans.length > 0
285
+ ? ` (${result.orphans.slice(0, 4).map(id => labels.get(id) ?? id).join(', ')}${result.orphans.length > 4 ? ', …' : ''})`
286
+ : '';
272
287
  const shown = total > edges.length ? ` Showing ${edges.length}.` : '';
273
288
  const pages = nodes === 1 ? '1 page' : `${nodes} pages`;
274
289
  const links = total === 1 ? '1 link' : `${total} links`;
275
290
  this.#state('graph-state', nodes === 0 ? `No pages to map in ${label}.` :
276
- `${pages}, ${links}, ${orphans} unlinked in ${label}.${shown}`);
291
+ `${pages}, ${links}, ${orphans} unlinked${orphanNames} in ${label}.${shown}`);
277
292
  } catch {
278
293
  if (scope !== this.#scope()) return;
279
294
  list.replaceChildren();
@@ -282,6 +297,40 @@ class OpenWikiPanel extends HTMLElement {
282
297
  button.disabled = false;
283
298
  }
284
299
  }
300
+
301
+ async #lint() {
302
+ const button = this.#field('lint');
303
+ if (button.disabled) return;
304
+ button.disabled = true;
305
+ const scope = this.#scope();
306
+ const label = scope === 'all-vaults' ? 'registered wikis' : 'this workspace';
307
+ this.#state('lint-state', `Auditing link health in ${label}…`);
308
+ const list = this.#field('issues');
309
+ list.replaceChildren();
310
+ try {
311
+ const result = await this.host.run('lint', scope === 'all-vaults' ? ['--all-vaults'] : []);
312
+ if (scope !== this.#scope()) return;
313
+ if (!result || !number(result.healthScore) || !number(result.totalHubs) || !Array.isArray(result.issues)) {
314
+ throw new Error('Invalid link health report');
315
+ }
316
+ const shownIssues = result.issues.slice(0, 12);
317
+ for (const issue of shownIssues) {
318
+ const row = element('li', '');
319
+ const fileName = typeof issue.file === 'string' ? issue.file.split('/').pop() : 'unknown';
320
+ row.append(element('strong', `[${issue.type}] ${fileName} `), element('span', issue.message || ''));
321
+ list.append(row);
322
+ }
323
+ const clipped = result.issues.length > shownIssues.length ? ` Showing ${shownIssues.length} of ${result.issues.length}.` : '';
324
+ const summary = `${result.healthScore}% health across ${result.totalHubs} page(s), ${result.brokenLinksCount ?? 0} broken link(s), ${result.orphanCount ?? 0} unlinked.${clipped}`;
325
+ this.#state('lint-state', summary, result.issues.length > 0);
326
+ } catch {
327
+ if (scope !== this.#scope()) return;
328
+ list.replaceChildren();
329
+ this.#state('lint-state', 'Link health audit unavailable. Try again.', true);
330
+ } finally {
331
+ button.disabled = false;
332
+ }
333
+ }
285
334
  }
286
335
 
287
336
  if (!customElements.get('openwiki-panel')) customElements.define('openwiki-panel', OpenWikiPanel);
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "jsdom": "^26.1.0",
16
- "total-recall-brain": "3.45.0"
16
+ "total-recall-brain": "3.47.0"
17
17
  }
18
18
  },
19
19
  "node_modules/@alcalzone/ansi-tokenize": {
@@ -5698,9 +5698,9 @@
5698
5698
  }
5699
5699
  },
5700
5700
  "node_modules/total-recall-brain": {
5701
- "version": "3.45.0",
5702
- "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.45.0.tgz",
5703
- "integrity": "sha512-HDYfROazZbsLHQsFPnlBcSpe1HL1QEi7JG8x/eNRUI06RxVQsZWl8LwBo2lTWjWRDrKmCy7BUmSXQDuWF3lrEw==",
5701
+ "version": "3.47.0",
5702
+ "resolved": "https://registry.npmjs.org/total-recall-brain/-/total-recall-brain-3.47.0.tgz",
5703
+ "integrity": "sha512-S4UIdbbTyxw7C4+rix+7oHMm/6gYkcxwNBcP6kRDySX40p5IgqVTKgHk1n8rmaKiyYFoIX/HZHlHSjNrHgn0Gw==",
5704
5704
  "dev": true,
5705
5705
  "license": "MIT",
5706
5706
  "workspaces": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-security",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Standalone security suite for Total Recall: static analysis, file and permission hardening schemes, AES-256-GCM encryption tools, and egress audit controls",
6
6
  "scripts": {
@@ -2,10 +2,16 @@
2
2
  "$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
3
3
  "id": "security",
4
4
  "name": "Security Assessments",
5
- "version": "0.4.0",
5
+ "version": "0.4.1",
6
6
  "description": "Machine and device security assessments for macOS, Linux, Windows, Android, Chromebook, iPhone and iPad, with explicit measured and manual coverage.",
7
7
  "license": "MIT",
8
- "requires": [{ "id": "auth", "range": ">=1.2.1" }],
8
+ "reuse": {
9
+ "commands": [
10
+ "total-recall auth secret",
11
+ "total-recall ssss status"
12
+ ],
13
+ "owned": "Machine and device security assessments, header probes, and security evidence audits across platforms."
14
+ },
9
15
  "branding": {
10
16
  "name": "Security Assessments",
11
17
  "color": "#31564b",
@@ -66,10 +72,21 @@
66
72
  }
67
73
  ],
68
74
  "gate": {
69
- "tasks": ["Audit a machine, network or the fleet", "Scan installed plugins or a source folder", "Harden SSH permissions", "Audit or protect a website you are authorized to test"],
75
+ "tasks": [
76
+ "Audit a machine, network or the fleet",
77
+ "Scan installed plugins or a source folder",
78
+ "Harden SSH permissions",
79
+ "Audit or protect a website you are authorized to test"
80
+ ],
70
81
  "owned_commands": [
71
- { "pattern": "nmap\\s", "use": "total-recall security network --json" },
72
- { "pattern": "lynis\\s", "use": "total-recall security machine --json" }
82
+ {
83
+ "pattern": "nmap\\s",
84
+ "use": "total-recall security network --json"
85
+ },
86
+ {
87
+ "pattern": "lynis\\s",
88
+ "use": "total-recall security machine --json"
89
+ }
73
90
  ]
74
91
  },
75
92
  "cli": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tr-plugin-ssss",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Canonical SSSS distribution, scaffolding and validation for Total Recall installations.",
@@ -12,7 +12,8 @@
12
12
  "scripts/",
13
13
  "cli.mjs",
14
14
  "DESIGN.md",
15
- "plugin.json"
15
+ "plugin.json",
16
+ "skills/"
16
17
  ],
17
18
  "scripts": {
18
19
  "test": "node scripts/refresh-scaffold.mjs --check && node --test test/*.test.mjs",