total-recall-brain 3.39.6 → 3.40.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.39.6",
3
+ "version": "3.40.0",
4
4
  "description": "Portable SSSS memory, local retrieval and task instructions with explicit plugin loading.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,7 +75,7 @@
75
75
  "homepage": "https://github.com/gregiteen/total-recall#readme",
76
76
  "dependencies": {
77
77
  "@simplewebauthn/server": "^13.3.2",
78
- "@ssss/cli": "npm:@gregiteen/ssss-cli@0.11.0",
78
+ "@ssss/cli": "npm:@gregiteen/ssss-cli@0.11.2",
79
79
  "bcrypt": "^6.0.0",
80
80
  "better-sqlite3": "^12.11.1",
81
81
  "cookie-parser": "^1.4.7",
@@ -43,7 +43,15 @@ export const CLIENTS = {
43
43
  globalMemoryPath: path.join(os.homedir(), '.dsh', 'memory', 'MEMORY.md'),
44
44
  globalUserPath: path.join(os.homedir(), '.dsh', 'memory', 'user.md'),
45
45
  localTarget: 'AGENTS.md',
46
- skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') }
46
+ skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') },
47
+ // DSH's default agent preset mounts dsh-skill-filesystem with no config, so
48
+ // the roots it scans are its own defaults: <dshHome>/skills (user-dsh) plus
49
+ // <repo>/.agents/skills and <repo>/.dsh/skills. Its host-plane
50
+ // `skill-filesystem` row is disabled because presets own local discovery, so
51
+ // a profile patch cannot configure these roots. Projecting Total Recall's
52
+ // authored skills into <dshHome>/skills is therefore the supported way to
53
+ // make them reach the `/` slash menu, globally and for every repo.
54
+ globalSkillsProjection: { dir: path.join('.dsh', 'skills') }
47
55
  },
48
56
  'deepseek-harness': {
49
57
  label: 'DeepSeek Harness',
@@ -51,7 +59,8 @@ export const CLIENTS = {
51
59
  globalMemoryPath: path.join(os.homedir(), '.dsh', 'memory', 'MEMORY.md'),
52
60
  globalUserPath: path.join(os.homedir(), '.dsh', 'memory', 'user.md'),
53
61
  localTarget: 'AGENTS.md',
54
- skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') }
62
+ skillsProjection: { scope: 'project', dir: path.join('.agents', 'skills') },
63
+ globalSkillsProjection: { dir: path.join('.dsh', 'skills') }
55
64
  },
56
65
  openclaw: {
57
66
  label: 'OpenClaw',
@@ -1036,6 +1045,29 @@ export default async function connect(args) {
1036
1045
  }
1037
1046
  }
1038
1047
 
1048
+ // Project GLOBAL skills into the target IDE's home-scoped Agent Skills
1049
+ // directory. Some harnesses read only a home root for skills and never scan a
1050
+ // repo directory, so a client that declares `globalSkillsProjection` needs this
1051
+ // as well as the project projection above. `connect <ide>` must leave a client
1052
+ // usable for both scopes; `init` reaches the same destination through
1053
+ // projectSkillsForScope().
1054
+ if (preset.globalSkillsProjection) {
1055
+ const globalAgentDir = path.join(os.homedir(), '.agent');
1056
+ const skills = discoverRepoSkills(globalAgentDir);
1057
+ if (skills.length > 0) {
1058
+ const destDir = path.join(os.homedir(), preset.globalSkillsProjection.dir);
1059
+ const skillResults = projectSkillsAsCommands(destDir, skills, opts);
1060
+ result.global_skill_commands = skillResults;
1061
+ const fresh = skillResults.filter(r => r.action === 'linked' || r.action === 'copied');
1062
+ if (fresh.length > 0) {
1063
+ result.notes.push(
1064
+ ` Global skills projected → ${destDir}/:\n` +
1065
+ fresh.map(r => ` ${r.name}`).join('\n')
1066
+ );
1067
+ }
1068
+ }
1069
+ }
1070
+
1039
1071
  // Record this client in the registry so `status` can report freshness
1040
1072
  registerClient(brainDir, opts.client, preset, result.projection?.targetPath || null);
1041
1073
 
package/src/cli/init.mjs CHANGED
@@ -249,6 +249,9 @@ export default async function init(args) {
249
249
  if (!fs.existsSync(path.join(authSource, 'plugin.json'))) throw new Error('Required auth scaffold is missing');
250
250
  if (!fs.existsSync(authTarget) && !opts.dryRun) fs.cpSync(authSource, authTarget, { recursive: true, errorOnExist: true, force: false });
251
251
 
252
+ // Every scaffold plugin's node_modules are wired in Step 2.5, after the skill
253
+ // tree has been copied into the brain (see the note there).
254
+
252
255
  // ── Step 2: Seed core skills ──
253
256
  logStep('2/4', 'Installing core skills into .agent/skills/');
254
257
 
@@ -304,9 +307,30 @@ export default async function init(args) {
304
307
  }
305
308
 
306
309
  // ── Step 2.5: Create data directories inside the meta-skill (brain dir) ──
310
+ // Wire every scaffold plugin's dependencies AFTER the skill copy above: the
311
+ // plugin directories only exist once the skill tree has been copied, so an
312
+ // earlier pass silently skipped everything except auth (copied separately).
313
+ // A plugin shipped without its node_modules aborts plugin loading for the whole
314
+ // scope — the symptom was 'Cannot find package express' on an unrelated command.
307
315
  if (!opts.dryRun) {
308
316
  const { preparePluginDependencies } = await import('../core/plugin-dependencies.mjs');
309
- preparePluginDependencies(path.join(brainDir, 'plugins', 'auth'));
317
+ const installedPluginsDir = path.join(brainDir, 'plugins');
318
+ for (const id of (fs.existsSync(installedPluginsDir) ? fs.readdirSync(installedPluginsDir) : [])) {
319
+ const target = path.join(installedPluginsDir, id);
320
+ if (!fs.existsSync(path.join(target, 'package.json'))) continue;
321
+ // One plugin whose declared dependency cannot be wired must not abort the
322
+ // whole run: the steps after this one — global skill projection into each
323
+ // IDE, then the vault compile — are independent of it. Before this guard a
324
+ // single unresolvable optional dependency (observed: '@gregiteen/ssss-cli'
325
+ // in a vendored plugin) killed `init` before skill projection, so no IDE
326
+ // ever received the global skills and their slash menus stayed empty with
327
+ // no error the user could connect to the cause.
328
+ try {
329
+ preparePluginDependencies(target);
330
+ } catch (err) {
331
+ logWarn(`Could not wire dependencies for plugin '${id}': ${err.message}`);
332
+ }
333
+ }
310
334
  }
311
335
  logStep('2.5/4', 'Creating user data directories inside meta-skill');
312
336
 
@@ -300,6 +300,22 @@ export async function run(argv = []) {
300
300
  const args = Array.isArray(argv) ? argv.slice(3) : [];
301
301
  const sub = args[0] || "status";
302
302
  const json = args.includes("--json");
303
+ const wantsHelp = args.includes("--help") || args.includes("-h");
304
+
305
+ if (wantsHelp || sub === "help") {
306
+ const subs = ["status", ...Object.keys(IMPLEMENTED)];
307
+ console.log(\`${pluginName} v${manifest.version}
308
+
309
+ Usage: total-recall ${id} <subcommand> [options]
310
+
311
+ Subcommands:
312
+ \${subs.map((s) => \` \${s}\`).join("\\n")}
313
+
314
+ Options:
315
+ --json Machine-readable output
316
+ --help, -h Show this help\`);
317
+ return;
318
+ }
303
319
 
304
320
  if (sub === "status") {
305
321
  const status = { plugin: ${JSON.stringify(id)}, implemented: Object.keys(IMPLEMENTED) };
@@ -312,7 +328,7 @@ export async function run(argv = []) {
312
328
 
313
329
  if (IMPLEMENTED[sub]) return IMPLEMENTED[sub](args.slice(1));
314
330
 
315
- console.error(\`${pluginName}: "\${sub}" is not implemented.\`);
331
+ console.error(\`${pluginName}: "\${sub}" is not implemented. Run 'total-recall ${id} --help' for available subcommands.\`);
316
332
  process.exitCode = 2;
317
333
  }
318
334
  export default run;
@@ -494,7 +510,7 @@ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf
494
510
 
495
511
  test('manifest identifies the plugin', () => {
496
512
  assert.equal(manifest.id, ${JSON.stringify(id)});
497
- assert.match(manifest.version, /^\\\\d+\\\\.\\\\d+\\\\.\\\\d+/);
513
+ assert.match(manifest.version, /^\\d+\\.\\d+\\.\\d+/);
498
514
  assert.equal(manifest.license, 'MIT');
499
515
  });
500
516
 
@@ -33,7 +33,17 @@ export async function installPlugin(args = []) {
33
33
  return;
34
34
  }
35
35
  const { autoCompileSurfaces } = await import('../../core/auto-compile.mjs');
36
- if (!await autoCompileSurfaces({ layer: isGlobal ? 'global' : 'project', semantic: false, force: true })) process.exitCode = 1;
36
+ // Compile the layer the plugin actually landed in. `scope` is not enough on
37
+ // its own: from a directory with no project brain, projectPluginsDir(cwd)
38
+ // resolves to the global plugins dir, so a plugin installed into the global
39
+ // brain still reports scope 'project'. Ask the resolver whether a project
40
+ // brain exists at all, and otherwise fall back to the global layer — the one
41
+ // that was really written. Assuming 'project' compiled nothing and printed
42
+ // "Auto-compile failed: No project brain found" after a successful install.
43
+ const { resolveBrainDir } = await import('../agent-dir.mjs');
44
+ let targetLayer = 'global';
45
+ try { resolveBrainDir('project'); targetLayer = 'project'; } catch { targetLayer = 'global'; }
46
+ if (!await autoCompileSurfaces({ layer: targetLayer, semantic: false, force: true })) process.exitCode = 1;
37
47
  } catch (err) {
38
48
  console.error(`❌ ${err.message}`);
39
49
  process.exit(1);
@@ -48,7 +48,13 @@ export function discoverBrainConsumers(roots, { onError } = {}) {
48
48
  if (deps.length) consumers.push({ root: real, file, pkg, deps });
49
49
  }
50
50
  for (const entry of fs.readdirSync(real, { withFileTypes: true })) {
51
- if (entry.isDirectory() && !entry.name.startsWith('.') && !ignored.has(entry.name)) walk(path.join(real, entry.name));
51
+ if (!entry.isDirectory() || ignored.has(entry.name)) continue;
52
+ // Dot-directories are skipped to avoid descending into .git and similar,
53
+ // but .agent holds the brain and its plugin installs. A plugin that runs
54
+ // `npm install` keeps its own node_modules there, and skipping the whole
55
+ // dot tree left those copies stranded on an old version.
56
+ if (entry.name.startsWith('.') && entry.name !== '.agent') continue;
57
+ walk(path.join(real, entry.name));
52
58
  }
53
59
  }
54
60
  for (const root of roots) walk(root);
@@ -316,6 +316,23 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
316
316
  }
317
317
 
318
318
  export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
319
+ const { embedding } = await getEmbeddingWithProvenance(text, model);
320
+ return embedding;
321
+ }
322
+
323
+ /**
324
+ * Resolve an embedding together with the provider and model that actually
325
+ * served it.
326
+ *
327
+ * The requested model name is not the serving model: Ollama is tried first and
328
+ * answers with a model discovered from the server, so a row labelled with the
329
+ * hosted default would misreport where the vector came from and whether the
330
+ * text ever left the machine. Persist the returned `provider`/`model`, never
331
+ * the requested default.
332
+ *
333
+ * @returns {Promise<{embedding: number[], provider: string, model: string}>}
334
+ */
335
+ export async function getEmbeddingWithProvenance(text, model = DEFAULT_EMBED_MODEL) {
319
336
  if (process.env.TR_EMBEDDINGS_DISABLED === '1') {
320
337
  throw new Error('Semantic embeddings explicitly disabled; local recall remains available');
321
338
  }
@@ -325,6 +342,8 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
325
342
  const openaiKey = resolveOpenAiApiKey();
326
343
 
327
344
  let embedding;
345
+ let provider = null;
346
+ let servedBy = null;
328
347
 
329
348
  // Local/mesh Ollama first: no key, no spend, no rate limit, and unaffected by
330
349
  // a hosted provider running out of credit. The model is discovered from the
@@ -342,6 +361,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
342
361
  model: ollamaModel,
343
362
  dims: EMBEDDING_DIMS,
344
363
  });
364
+ if (embedding) { provider = 'ollama'; servedBy = ollamaModel; }
345
365
  }
346
366
  }
347
367
  } catch (err) {
@@ -354,6 +374,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
354
374
  if (!embedding && openRouterKey) {
355
375
  try {
356
376
  embedding = await getOpenRouterEmbedding(input);
377
+ if (embedding) { provider = 'openrouter'; servedBy = 'openai/text-embedding-3-small'; }
357
378
  } catch (err) {
358
379
  logger.debug('embeddings: OpenRouter failed, trying Google fallback', { err: err.message });
359
380
  }
@@ -362,6 +383,9 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
362
383
  if (!embedding && googleKey) {
363
384
  try {
364
385
  embedding = await getGoogleEmbedding(input, model);
386
+ // getGoogleEmbedding resolves the concrete model internally; record that,
387
+ // not the requested default, so provenance is exact.
388
+ if (embedding) { provider = 'google'; servedBy = await resolveEmbeddingModel(model); }
365
389
  } catch (err) {
366
390
  logger.debug('embeddings: Google API failed, trying OpenAI fallback', { err: err.message });
367
391
  }
@@ -370,6 +394,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
370
394
  if (!embedding && openaiKey) {
371
395
  try {
372
396
  embedding = await getOpenAIEmbedding(input);
397
+ if (embedding) { provider = 'openai'; servedBy = 'text-embedding-3-small'; }
373
398
  } catch (err) {
374
399
  logger.debug('embeddings: OpenAI fallback also failed', { err: err.message });
375
400
  throw err;
@@ -383,7 +408,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
383
408
  );
384
409
  }
385
410
 
386
- return embedding;
411
+ return { embedding, provider, model: servedBy };
387
412
  }
388
413
 
389
414
  export function cosineSimilarity(a, b) {
@@ -511,7 +536,10 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
511
536
  }
512
537
 
513
538
  try {
514
- const parentEmbedding = await getEmbedding(text, undefined, model);
539
+ // Record the provider/model that actually served this vector. The row used
540
+ // to be stamped with the requested default even when local Ollama answered,
541
+ // which made provenance — and "did this text leave the machine?" — unreadable.
542
+ const { embedding: parentEmbedding, model: servedModel } = await getEmbeddingWithProvenance(text, model);
515
543
  const now = new Date().toISOString();
516
544
  const embeddingJson = JSON.stringify(parentEmbedding);
517
545
 
@@ -524,7 +552,7 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
524
552
  generated_at=excluded.generated_at,
525
553
  content_sha256=excluded.content_sha256,
526
554
  embedding_json=excluded.embedding_json
527
- `).run(node.slug, model, now, contentHash, embeddingJson);
555
+ `).run(node.slug, servedModel || model, now, contentHash, embeddingJson);
528
556
 
529
557
  const rowId = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(node.slug).rowid;
530
558
  vssReplaceVault(db, rowId, embeddingJson);
@@ -658,7 +686,7 @@ export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts
658
686
 
659
687
  for (let i = 0; i < chunks.length; i++) {
660
688
  const key = chunks.length === 1 ? sessionId : `${sessionId}:chunk-${i}`;
661
- const embedding = await getEmbedding(chunks[i], undefined, model);
689
+ const { embedding, model: servedModel } = await getEmbeddingWithProvenance(chunks[i], model);
662
690
  const snippet = chunks[i].slice(0, 300);
663
691
 
664
692
  db.transaction(() => {
@@ -670,7 +698,7 @@ export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts
670
698
  model=excluded.model,
671
699
  generated_at=excluded.generated_at,
672
700
  embedding_json=excluded.embedding_json
673
- `).run(key, sessionId, i, chunks.length, snippet, model, new Date().toISOString(), JSON.stringify(embedding));
701
+ `).run(key, sessionId, i, chunks.length, snippet, servedModel || model, new Date().toISOString(), JSON.stringify(embedding));
674
702
 
675
703
  const rowId = db.prepare('SELECT rowid FROM session_embeddings WHERE key = ?').get(key).rowid;
676
704
  vssReplaceSession(db, rowId, JSON.stringify(embedding));
@@ -1,18 +1,21 @@
1
- /** Markdown metadata is data: YAML/JSON only, with no executable engines. */
2
- import * as yamlModule from 'js-yaml';
3
- const yaml = yamlModule.default || yamlModule;
4
- // Existing source checkouts can still have the legacy CommonJS parser installed.
5
- const load = yaml.safeLoad || yaml.load;
6
- const dump = yaml.safeDump || yaml.dump;
7
- // Preserve the existing YAML 1.2 scalar rules plus dates/merge/collection tags.
8
- const schema = yaml.CORE_SCHEMA?.withTags
9
- ? yaml.CORE_SCHEMA.withTags(yaml.timestampTag, yaml.mergeTag, yaml.binaryTag, yaml.omapTag, yaml.pairsTag, yaml.setTag)
10
- : yaml.DEFAULT_SAFE_SCHEMA;
1
+ /**
2
+ * Markdown metadata is data: YAML/JSON only, with no executable engines.
3
+ *
4
+ * YAML frontmatter is read and written by the SSSS parser (@ssss/cli/frontmatter),
5
+ * the single implementation shared with every SSSS host, so Total Recall and SSSS
6
+ * can never disagree about a vault file. This wrapper keeps the gray-matter-style
7
+ * API, the fence/language checks and the CRLF handling.
8
+ */
9
+ import { parseFrontmatter, serializeDocument, repairEscapedNewlines } from '@ssss/cli/frontmatter';
10
+
11
+ // YAML tags (`!!js/function`, `!!python/object`, ...) can request code; SSSS reads
12
+ // them as plain text, so refuse them up front instead of storing them as data.
13
+ const YAML_TAG = /(?:^|[:,\[{-])[ \t]*!!\S/m;
11
14
 
12
15
  export default function matter(input) {
13
16
  const original = Buffer.isBuffer(input) ? input.toString('utf8') : typeof input === 'string' ? input : input?.content;
14
17
  if (typeof original !== 'string') throw new TypeError('Markdown content must be a string');
15
- const text = original.replace(/^\uFEFF/, '');
18
+ const text = repairEscapedNewlines(original.replace(/^/, ''));
16
19
  const file = { data: {}, content: text, excerpt: '', orig: original };
17
20
  const opening = /^---([^\r\n]*)\r?\n/.exec(text);
18
21
  if (!opening || opening[1].startsWith('-')) return file;
@@ -22,7 +25,8 @@ export default function matter(input) {
22
25
  const remainder = text.slice(opening[0].length);
23
26
  const closing = /^---(?:\r?\n|$)/m.exec(remainder);
24
27
  const metadata = closing ? remainder.slice(0, closing.index) : remainder;
25
- file.data = (language === 'json' ? JSON.parse(metadata || '{}') : load(metadata, { schema })) || {};
28
+ if (language !== 'json' && YAML_TAG.test(metadata)) throw new Error('YAML tags are not allowed in frontmatter');
29
+ file.data = (language === 'json' ? JSON.parse(metadata || '{}') : parseFrontmatter(metadata.replace(/\r\n/g, '\n'), { strict: true })) || {};
26
30
  file.content = closing ? remainder.slice(closing.index + closing[0].length) : '';
27
31
  file.matter = metadata;
28
32
  file.language = language;
@@ -30,11 +34,14 @@ export default function matter(input) {
30
34
  }
31
35
 
32
36
  const newline = value => value.endsWith('\n') ? value : value + '\n';
33
- matter.stringify = (input, data, options = {}) => {
37
+ // Dates become ISO strings (SSSS keeps timestamps as strings); undefined is dropped.
38
+ const plain = value => JSON.parse(JSON.stringify(value, (_key, v) => (v instanceof Date ? v.toISOString() : v)));
39
+
40
+ matter.stringify = (input, data) => {
34
41
  if (typeof input === 'string' && data == null) return input;
35
42
  const file = typeof input === 'string' ? matter(input) : input;
36
43
  if (!file || typeof file.content !== 'string') throw new TypeError('Markdown content must be a string');
37
- const metadata = { ...file.data, ...(data ?? {}) };
38
- const serialized = dump(metadata, options).trim();
39
- return (serialized === '{}' ? '' : `---\n${serialized}\n---\n`) + newline(file.content);
44
+ const metadata = plain({ ...file.data, ...(data ?? {}) });
45
+ const head = Object.keys(metadata).length ? serializeDocument(metadata, '') : '';
46
+ return head + newline(file.content);
40
47
  };
@@ -71,8 +71,22 @@ export async function executePluginState(request) {
71
71
  }
72
72
  // A guard serializes state read/compare/commit across native worker processes.
73
73
  const lock = path.join(root, '.mutation-lock');
74
- try { fs.mkdirSync(lock, { mode: 0o700 }); }
75
- catch { throw new Error('Plugin state mutation is busy or its ownership guard is unreadable'); }
74
+ try {
75
+ fs.mkdirSync(lock, { mode: 0o700 });
76
+ } catch (err) {
77
+ // Distinguish a genuine concurrent mutation from an environment failure so
78
+ // callers are not told the store is busy when it is actually inaccessible.
79
+ if (err && err.code === 'EEXIST') {
80
+ throw new Error('Plugin state mutation is busy; another mutation holds the guard');
81
+ }
82
+ if (err && (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'EROFS')) {
83
+ throw new Error(
84
+ `Plugin state store is not writable (${err.code}): cannot create the mutation guard at ${lock}. ` +
85
+ 'Grant write access to this brain root, or point TR_SECRETS_BRAIN at a store you can write to.'
86
+ );
87
+ }
88
+ throw new Error(`Plugin state mutation guard could not be created at ${lock}: ${err?.message || err}`);
89
+ }
76
90
  try {
77
91
  const durableKeys = new FileIdempotencyStore(path.join(root, '.idempotency'));
78
92
  const intentHash = crypto.createHash('sha256').update(JSON.stringify({ path: relative, action, value, expectedVersion, format: request.format })).digest('hex');
@@ -758,7 +758,10 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
758
758
  logger.info('surface', 'Semantic embeddings explicitly disabled; local indexes updated.', { derivedDir });
759
759
  } else try {
760
760
  const { buildEmbeddingsIndex } = await import('./embeddings.mjs');
761
- const built = await buildEmbeddingsIndex(nodes, derivedDir);
761
+ // Forward `force`: the builder is content-hash incremental and skips a node
762
+ // whose text is unchanged, so without this `compile --force` rebuilt nothing
763
+ // and rows kept an outdated model label.
764
+ const built = await buildEmbeddingsIndex(nodes, derivedDir, { force });
762
765
  semanticResult = {
763
766
  indexed: built.built,
764
767
  skipped: built.skipped,