total-recall-brain 3.46.1 → 3.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/frontend/dist/assets/index-CpsWeV4X.css +1 -0
  2. package/frontend/dist/assets/{index-D1goIPjC.js → index-poecbHSl.js} +100 -100
  3. package/frontend/dist/brand/total-recall-lockup-transparent.png +0 -0
  4. package/frontend/dist/index.html +2 -2
  5. package/frontend/dist/sw.js +1 -1
  6. package/frontend/src/App.tsx +15 -81
  7. package/frontend/src/components/DashboardOverview.tsx +164 -0
  8. package/frontend/src/components/brand/BrandMark.tsx +8 -6
  9. package/frontend/src/components/plugins/PluginWorkspace.tsx +112 -57
  10. package/frontend/src/components/workspace/PluginHome.tsx +3 -3
  11. package/frontend/src/index.css +43 -11
  12. package/package.json +1 -1
  13. package/scaffold/.agent/skills/total-recall/plugins/git/package-lock.json +6 -6
  14. package/scaffold/.agent/skills/total-recall/plugins/git/package.json +1 -1
  15. package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +8 -1
  16. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +5 -33
  17. package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-fleet-ops.md +16 -0
  18. package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-publish.md +16 -0
  19. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +119 -10
  20. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
  21. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +1 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +52 -4
  23. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +7 -7
  24. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +23 -22
  25. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +20 -33
  26. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +42 -49
  27. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/markdown-links.mjs +112 -0
  28. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +3 -2
  29. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +206 -60
  30. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +2 -1
  31. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +18 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/panel.js +51 -2
  33. package/src/cli/context.mjs +6 -2
  34. package/src/core/context-compactor.mjs +2 -0
  35. package/src/core/dashboard-bundle.mjs +12 -3
  36. package/src/server/routes/vault.mjs +1 -0
  37. 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'));
@@ -139,15 +253,22 @@ export function staleness(root, { git = gitRun } = {}) {
139
253
  }
140
254
  } catch { files = null; }
141
255
 
142
- const log = git('git', ['-C', root, 'log', '-1', '--format=%cI', '--', '.', ':!openwiki'], { encoding: 'utf8' });
256
+ // A commit that touches only the ignored paths (a release's version pins and
257
+ // compiled instructions) is not a change to document.
258
+ const scope = ['--', '.', ':!openwiki', ...ignorePathspecs(effectiveIgnores(root))];
259
+ const log = git('git', ['-C', root, 'log', '-1', '--format=%cI', ...scope], { encoding: 'utf8' });
143
260
  const lastCodeChange = !log.error && log.status === 0 ? String(log.stdout || '').trim() || null : null;
144
261
 
145
262
  let commitsSince = null;
146
263
  if (generationCommit || generatedAt) {
147
264
  const range = generationCommit ? [`${generationCommit}..HEAD`] : [`--since=${generatedAt}`, 'HEAD'];
148
- const count = git('git', ['-C', root, 'rev-list', '--count', ...range, '--', '.', ':!openwiki'], { encoding: 'utf8' });
265
+ const count = git('git', ['-C', root, 'rev-list', '--count', ...range, ...scope], { encoding: 'utf8' });
149
266
  const value = String(count.stdout || '').trim();
150
267
  if (!count.error && count.status === 0 && /^\d+$/.test(value)) commitsSince = Number(value);
268
+ if (commitsSince > 0) {
269
+ 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 });
270
+ if (!patches.error && patches.status === 0) commitsSince = Math.max(0, commitsSince - versionOnlyCommits(String(patches.stdout || '')));
271
+ }
151
272
  }
152
273
  const incomplete = generatedStatus === 'interrupted' || stillGenerating(root);
153
274
  const stale = incomplete || generatedAt === null || commitsSince === null || commitsSince > 0;
@@ -287,12 +408,44 @@ function writeReceipt(root, name, value) {
287
408
  }
288
409
  }
289
410
 
290
- function redact(value, key) {
411
+ export const CREDIT_REFUSAL = /\b402\b|insufficient credits|requires more credits|credit limit/i;
412
+
413
+ export function redact(value, key) {
291
414
  let text = String(value || '');
292
415
  if (key) text = text.split(key).join('[REDACTED]');
293
416
  return text.replace(/\bBearer\s+\S+/gi, 'Bearer [REDACTED]').replace(/\bsk-[\w-]+/g, '[REDACTED]');
294
417
  }
295
418
 
419
+ // The model is the operator's choice; models and prices change too often for a default here.
420
+ 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>';
421
+
422
+ export function buildGeneratorInvocation({ baseArgs = [], mode = 'update', config = {}, key = '' } = {}) {
423
+ const selectedModel = String(config.model || '').trim();
424
+ if (!selectedModel) throw new Error(NO_MODEL);
425
+ const selectedProvider = config.provider || 'openrouter';
426
+ const cmdArgs = [...baseArgs, `--${mode}`, '-p', '--modelId', selectedModel];
427
+ const childEnv = { ...process.env };
428
+ if (selectedProvider === 'google') {
429
+ childEnv.GOOGLE_API_KEY = key;
430
+ childEnv.GEMINI_API_KEY = key;
431
+ childEnv.OPENWIKI_PROVIDER = 'gemini';
432
+ delete childEnv.OPENAI_API_KEY;
433
+ delete childEnv.OPENROUTER_API_KEY;
434
+ } else {
435
+ childEnv.OPENROUTER_API_KEY = key;
436
+ childEnv.OPENWIKI_PROVIDER = 'openrouter';
437
+ if (config.max_tokens) {
438
+ childEnv.OPENWIKI_OPENROUTER_MAX_TOKENS = String(config.max_tokens);
439
+ childEnv.OPENWIKI_MAX_OUTPUT_TOKENS = String(config.max_tokens);
440
+ }
441
+ delete childEnv.OPENAI_API_KEY;
442
+ }
443
+ if (config.timeout_ms) {
444
+ childEnv.OPENWIKI_TIMEOUT = String(config.timeout_ms);
445
+ }
446
+ return { cmdArgs, childEnv, selectedModel, selectedProvider };
447
+ }
448
+
296
449
  function finishRefresh(project, { before, result, git, key, mode, startedAt, via, clock }) {
297
450
  const after = staleness(project.root, { git });
298
451
  const exitCode = result.error ? (result.error.code || 1) : result.signal ? 1 : result.status;
@@ -363,34 +516,17 @@ export function refreshProject(project, {
363
516
  ({ command, args, via } = resolved);
364
517
  }
365
518
 
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
- }
519
+ const config = loadOpenWikiConfig();
520
+ const { cmdArgs, childEnv } = buildGeneratorInvocation({
521
+ baseArgs: args,
522
+ mode,
523
+ config: { ...config, model: model || config.model, provider: provider || config.provider },
524
+ key,
525
+ });
392
526
 
527
+ ensureDefaultIgnores(project.root);
393
528
  const startedAt = now;
529
+ writeReceipt(project.root, '.refresh-started.json', { startedAt: new Date(startedAt).toISOString(), sourceCommit: before.head || null });
394
530
  const result = run(command, cmdArgs, {
395
531
  cwd: project.root,
396
532
  env: childEnv,
@@ -421,34 +557,17 @@ export async function refreshProjectAsync(project, {
421
557
  ({ command, args, via } = resolved);
422
558
  }
423
559
 
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
- }
560
+ const config = loadOpenWikiConfig();
561
+ const { cmdArgs, childEnv } = buildGeneratorInvocation({
562
+ baseArgs: args,
563
+ mode,
564
+ config: { ...config, model: model || config.model, provider: provider || config.provider },
565
+ key,
566
+ });
450
567
 
568
+ ensureDefaultIgnores(project.root);
451
569
  const startedAt = now;
570
+ writeReceipt(project.root, '.refresh-started.json', { startedAt: new Date(startedAt).toISOString(), sourceCommit: before.head || null });
452
571
  const result = await new Promise((resolve) => {
453
572
  const proc = spawnProcess(command, cmdArgs, {
454
573
  cwd: project.root,
@@ -465,22 +584,49 @@ export async function refreshProjectAsync(project, {
465
584
  return finishRefresh(project, { before, result, git, key, mode, startedAt, via, clock });
466
585
  }
467
586
 
468
- /** Is a refresh due for this project under the configured interval? */
587
+ /**
588
+ * Start time (ms) of the latest generation attempt, or NaN when none is recorded.
589
+ * `.refresh-started.json` is written before the generator runs, so an attempt
590
+ * the task runner killed still counts; the other two are written by the
591
+ * generator when it begins and by the driver when an attempt ends.
592
+ */
593
+ export function lastAttemptStart(root, generatedAt = null) {
594
+ const times = [
595
+ readJson(path.join(root, 'openwiki', '.refresh-started.json'))?.startedAt,
596
+ readJson(path.join(root, 'openwiki', '.refresh-attempt.json'))?.startedAt,
597
+ generatedAt,
598
+ ].map((value) => (typeof value === 'string' ? Date.parse(value) : NaN)).filter(Number.isFinite);
599
+ return times.length ? Math.max(...times) : NaN;
600
+ }
601
+
602
+ /**
603
+ * Is a refresh due for this project under the configured interval?
604
+ *
605
+ * Every attempt, including a retry of an interrupted or failed one, waits the
606
+ * full interval after the previous attempt began. Retrying an interrupted run
607
+ * on every scheduler slot spent about $10 a day on 2026-11-12/13: the task
608
+ * runner killed the same generations every 30 minutes and they never finished.
609
+ */
469
610
  export function isDue(project, { lockMs = DEFAULT_LOCK_MS, now = Date.now(), git = gitRun, force = false } = {}) {
470
611
  if (!isGitRepo(project.root, { git })) {
471
612
  return { due: false, skipped: true, reason: 'not a git repository' };
472
613
  }
473
614
  const state = staleness(project.root, { git });
474
615
  if (force) return { due: true, uninitialized: !hasWiki(project.root), ...state };
616
+ const last = lastAttemptStart(project.root, state.generatedAt);
617
+ const retryAt = Number.isFinite(last) && now - last < lockMs ? new Date(last + lockMs).toISOString() : null;
475
618
  if (!hasWiki(project.root)) {
619
+ if (retryAt) return { due: false, held: true, uninitialized: true, reason: `initialization attempted recently; next attempt after ${retryAt}`, ...state };
476
620
  return { due: true, uninitialized: true, reason: 'uninitialized (missing openwiki/ pages)', ...state };
477
621
  }
478
622
  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 };
623
+ const incomplete = state.generatedStatus === 'interrupted' || stillGenerating(project.root);
624
+ if (retryAt) {
625
+ return incomplete
626
+ ? { due: false, held: true, reason: `incomplete generation attempted recently; resumes after ${retryAt}`, ...state }
627
+ : { due: false, reason: 'refreshed recently', ...state };
483
628
  }
629
+ if (incomplete) return { due: true, reason: 'resume incomplete generation', ...state };
484
630
  return { due: true, ...state };
485
631
  }
486
632
 
@@ -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, hasWiki, 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';
@@ -378,8 +378,19 @@ export async function propagateUpdates({
378
378
  return { ok: false, error: error.message, eligibleCount: eligible.length, updatedCount: 0, reports: [] };
379
379
  }
380
380
  const reports = [];
381
+ let creditStopReason = null;
381
382
 
382
383
  for (const project of eligible) {
384
+ if (creditStopReason) {
385
+ reports.push({
386
+ name: project.name,
387
+ root: project.root,
388
+ action: 'deferred',
389
+ reason: creditStopReason,
390
+ generatorVersion: targetVersion
391
+ });
392
+ continue;
393
+ }
383
394
  try {
384
395
  const outcome = await refresher(project, {
385
396
  key,
@@ -392,6 +403,9 @@ export async function propagateUpdates({
392
403
  credential: from,
393
404
  generatorVersion: targetVersion
394
405
  });
406
+ if (CREDIT_REFUSAL.test(String(outcome?.reason || ''))) {
407
+ creditStopReason = `deferred: provider refused ${project.name} for credit; add credit, then propagate again`;
408
+ }
395
409
  } catch (err) {
396
410
  reports.push({
397
411
  name: project.name,
@@ -400,6 +414,9 @@ export async function propagateUpdates({
400
414
  reason: err.message,
401
415
  generatorVersion: targetVersion
402
416
  });
417
+ if (CREDIT_REFUSAL.test(String(err?.message || ''))) {
418
+ creditStopReason = `deferred: provider refused ${project.name} for credit; add credit, then propagate again`;
419
+ }
403
420
  }
404
421
  }
405
422
 
@@ -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);
@@ -100,9 +100,13 @@ async function printCompacted({ query, actions, budget, format, version, rulesHa
100
100
  // Measure the actual floor through the same encoder; leave room for the changed budget digits on retry.
101
101
  const floor = await compact(error.requiredBudget);
102
102
  const requiredBudget = estimateTokens(`${output(floor)}\n`) + 16;
103
+ const requiredIds = Array.isArray(error.requiredIds) ? error.requiredIds : [];
104
+ const requiredPreview = requiredIds.length
105
+ ? ` required_set:${requiredIds.slice(0, 6).join(',')}${requiredIds.length > 6 ? `,+${requiredIds.length - 6}` : ''}`
106
+ : '';
103
107
  console.log(format === 'json'
104
- ? JSON.stringify({ ready: false, reason: 'budget-overflow', context: '', stats: { version, budget, required_budget: requiredBudget, overage: requiredBudget - budget, rules: error.ruleCount, token_measurement: 'estimated_chars_divided_by_four' } })
105
- : `ready:false reason:budget-overflow budget:${budget} required_budget:${requiredBudget} overage:${requiredBudget - budget} rules:${error.ruleCount} version:${version}\nThis budget cannot hold all ${error.ruleCount} applicable rules even compacted; \`total-recall rules audit\` lists them by size. Retry with --budget ${requiredBudget}; do not act until ready:true.`);
108
+ ? JSON.stringify({ ready: false, reason: 'budget-overflow', context: '', stats: { version, budget, required_budget: requiredBudget, overage: requiredBudget - budget, rules: error.ruleCount, required_ids: requiredIds, token_measurement: 'estimated_chars_divided_by_four' } })
109
+ : `ready:false reason:budget-overflow budget:${budget} required_budget:${requiredBudget} overage:${requiredBudget - budget} rules:${error.ruleCount}${requiredPreview} version:${version}\nThis budget cannot hold all ${error.ruleCount} applicable rules even compacted; \`total-recall rules audit\` lists them by size. Retry with --budget ${requiredBudget}; do not act until ready:true.`);
106
110
  process.exitCode = 2;
107
111
  return false;
108
112
  }
@@ -106,6 +106,7 @@ export function compactRules(items, { budget, expand = [], header, complete = fa
106
106
  const error = new Error(`Budget ${budget} cannot hold the capsule header; the minimum is ${floor}`);
107
107
  error.requiredBudget = floor;
108
108
  error.ruleCount = items.length;
109
+ error.requiredIds = items.map(item => item.slug);
109
110
  throw error;
110
111
  }
111
112
 
@@ -127,6 +128,7 @@ export function compactRules(items, { budget, expand = [], header, complete = fa
127
128
  const error = new Error(`Budget ${budget} cannot hold every rule; the minimum is ${completeFloor}`);
128
129
  error.requiredBudget = completeFloor;
129
130
  error.ruleCount = items.length;
131
+ error.requiredIds = items.map(item => item.slug);
130
132
  throw error;
131
133
  }
132
134
  }
@@ -14,8 +14,10 @@
14
14
  * background with the frontend's own build script, and keeps serving the
15
15
  * current bundle until the new one is complete. It never installs
16
16
  * dependencies: missing build tools are reported, not repaired behind the
17
- * operator's back. An npm tarball gives every file the same mtime, so an
18
- * installed package is never stale and never builds.
17
+ * operator's back. An installed package (a root inside node_modules) serves
18
+ * the bundle its release built, which the publish gate checked, and is never
19
+ * stale and never builds: npm does not keep the tarball's file times on every
20
+ * install, so comparing times there reports a stale bundle that is not.
19
21
  */
20
22
  import { spawn } from 'node:child_process';
21
23
  import fs from 'node:fs';
@@ -60,9 +62,10 @@ export async function newestUnder(target) {
60
62
  * Returns { ok, reason, bundle, source }; never throws, never prints, so the
61
63
  * decision can be tested apart from how it is reported.
62
64
  */
63
- export async function checkDistFreshness({ distDir, sourcePaths }) {
65
+ export async function checkDistFreshness({ distDir, sourcePaths, packaged = false }) {
64
66
  const bundle = await newestUnder(distDir);
65
67
  if (!bundle) return { ok: false, reason: 'missing-bundle', bundle: null, source: null };
68
+ if (packaged) return { ok: true, reason: 'packaged', bundle, source: null };
66
69
 
67
70
  let source = null;
68
71
  for (const candidate of sourcePaths) {
@@ -76,6 +79,11 @@ export async function checkDistFreshness({ distDir, sourcePaths }) {
76
79
  return { ok: true, reason: 'current', bundle, source };
77
80
  }
78
81
 
82
+ /** An npm-installed package: its root sits inside a node_modules directory. */
83
+ export function isInstalledPackage(root) {
84
+ return path.resolve(root).split(path.sep).includes('node_modules');
85
+ }
86
+
79
87
  /** Every input whose change changes the built dashboard. */
80
88
  export function dashboardTargets(root) {
81
89
  const frontend = path.join(root, 'frontend');
@@ -85,6 +93,7 @@ export function dashboardTargets(root) {
85
93
  } catch { /* no frontend directory: nothing to compare */ }
86
94
  return {
87
95
  distDir: path.join(frontend, 'dist'),
96
+ packaged: isInstalledPackage(root),
88
97
  sourcePaths: [
89
98
  path.join(frontend, 'src'),
90
99
  path.join(frontend, 'public'),
@@ -1,5 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
  import { requireAuth, requireScope } from '../auth.mjs';
4
5
  import {
5
6
  serverError,