total-recall-brain 3.46.2 → 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.
- package/frontend/dist/assets/index-CpsWeV4X.css +1 -0
- package/frontend/dist/assets/{index-D1goIPjC.js → index-poecbHSl.js} +100 -100
- package/frontend/dist/brand/total-recall-lockup-transparent.png +0 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +1 -1
- package/frontend/src/App.tsx +15 -81
- package/frontend/src/components/DashboardOverview.tsx +164 -0
- package/frontend/src/components/brand/BrandMark.tsx +8 -6
- package/frontend/src/components/plugins/PluginWorkspace.tsx +112 -57
- package/frontend/src/components/workspace/PluginHome.tsx +3 -3
- package/frontend/src/index.css +43 -11
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/git/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +8 -1
- package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +5 -33
- package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-fleet-ops.md +16 -0
- package/scaffold/.agent/skills/total-recall/plugins/git/ui/chunks/git-publish.md +16 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +119 -10
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +52 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +7 -7
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +23 -22
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +20 -33
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +42 -49
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/markdown-links.mjs +112 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +3 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +206 -60
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +18 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/panel.js +51 -2
- package/src/cli/context.mjs +6 -2
- package/src/core/context-compactor.mjs +2 -0
- package/src/server/routes/vault.mjs +1 -0
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 =
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
480
|
-
if (
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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);
|
package/src/cli/context.mjs
CHANGED
|
@@ -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
|
}
|