total-recall-brain 3.28.2 → 3.30.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 (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -189,24 +189,40 @@ export function persistTaskToDisk(task, queueDir) {
189
189
  return filepath;
190
190
  }
191
191
 
192
+ // ─── Research Pipeline ──────────────────────────────────────────────────────────
193
+
194
+ /** A failed research item is retried at most this many times, then stays failed. */
195
+ export const MAX_RESEARCH_ATTEMPTS = 3;
196
+
197
+ /**
198
+ * The research pipeline. It ends at improvement: there is deliberately no
199
+ * monitoring or expansion phase, because research must never spawn research.
200
+ */
201
+ export const RESEARCH_PHASES = Object.freeze({
202
+ acquisition: {
203
+ step: 1, label: 'Acquisition', category: 'proactive-research',
204
+ body: (i) => `Run knowledge acquisition cycle for queued topic: ${i.topic}.\nNotes: ${i.notes || 'None'}`,
205
+ },
206
+ deliberation: {
207
+ step: 2, label: 'Deliberation', category: 'system2-deliberation',
208
+ body: (i) => `Run deep System 2 cognitive deliberation for topic: ${i.topic}.\nTarget node: ${i.node_slug || 'pending'}`,
209
+ },
210
+ improvement: {
211
+ step: 3, label: 'Improvement', category: 'memory-maintenance',
212
+ body: (i) => `Run document improvement and formatting refinement for topic: ${i.topic}.\nTarget node: ${i.node_slug || 'pending'}`,
213
+ },
214
+ });
215
+ const RESEARCH_PHASE_COUNT = Object.keys(RESEARCH_PHASES).length;
216
+
192
217
  // ─── Idle Task Generation ───────────────────────────────────────────────────────
193
218
 
194
219
  let _idleCycleCounter = 0;
195
220
 
196
221
  /**
197
222
  * Generate an idle improvement task when the explicit queue is empty.
198
- * Weighted toward proactive research — 3 of every 9 ticks do real web search.
199
- *
200
- * Cycle (9 ticks):
201
- * 0: proactive-research — web search + synthesize from agenda
202
- * 1: inference — draw System 2 conclusions from vault clusters
203
- * 2: proactive-research — web search (second topic)
204
- * 3: staleness-check — flag outdated facts
205
- * 4: proactive-research — web search (third topic)
206
- * 5: inference — draw more conclusions
207
- * 6: post-mortem — re-analyze most recent session
208
- * 7: clarity-review — improve a vault node
209
- * 8: cutoff-audit — check for training-drift
223
+ * Purely local maintenance, round-robin: inference, post-mortem, clarity
224
+ * review, memory compaction. Idle ticks never research on their own; research
225
+ * only runs for topics a human asked for or the project gate approved.
210
226
  */
211
227
  export function generateIdleTask({ vaultDir, sessionsDir }) {
212
228
  // Only allow purely local task generation strategies.
@@ -229,23 +245,6 @@ export function generateIdleTask({ vaultDir, sessionsDir }) {
229
245
  }
230
246
  }
231
247
 
232
- /**
233
- * Generate a proactive research task — pulls the next topic from the agenda
234
- * and queues a full web search + synthesis cycle.
235
- */
236
- function generateProactiveResearchTask() {
237
- return {
238
- type: 'task',
239
- slug: `proactive-research-${Date.now().toString(36)}`,
240
- priority: 55,
241
- category: 'proactive-research',
242
- status: 'pending',
243
- created_by: 'scheduler-idle',
244
- reason: 'Idle task: pull next topic from research agenda and execute web search + synthesis.',
245
- body: 'Run one knowledge acquisition cycle: pull highest-priority pending topic from agenda, gather from web/wikipedia/arXiv/npm/github, synthesize with LLM, write cited fact node to vault.',
246
- };
247
- }
248
-
249
248
  /**
250
249
  * Pick a random active memory node and create a clarity review task.
251
250
  */
@@ -285,54 +284,6 @@ function generateMemoryCompactionTask(vaultDir) {
285
284
  };
286
285
  }
287
286
 
288
- /**
289
- * Pick the oldest fact node and create a staleness check task.
290
- */
291
- function generateStalenessCheckTask(vaultDir) {
292
- const nodes = getNodes(vaultDir)
293
- .filter((n) => n.status === 'active' && n.category === 'facts')
294
- .sort((a, b) => {
295
- const aDate = new Date(a.last_accessed || a.updated || 0).getTime();
296
- const bDate = new Date(b.last_accessed || b.updated || 0).getTime();
297
- return aDate - bDate; // oldest first
298
- });
299
-
300
- if (nodes.length === 0) {
301
- // No facts? Pick any old node
302
- const allNodes = getNodes(vaultDir).filter((n) => n.status === 'active');
303
- if (allNodes.length === 0) {
304
- return makeFallbackTask('research-acquisition', 'No facts to verify');
305
- }
306
- const oldest = allNodes.sort((a, b) => {
307
- return new Date(a.updated || 0).getTime() - new Date(b.updated || 0).getTime();
308
- })[0];
309
- return {
310
- type: 'task',
311
- slug: `staleness-check-${oldest.slug}-${Date.now().toString(36)}`,
312
- priority: 25,
313
- category: 'research-acquisition',
314
- target: oldest.slug,
315
- status: 'pending',
316
- created_by: 'scheduler-idle',
317
- reason: `Idle task: verify if "${oldest.title}" is still current.`,
318
- body: `## Objective\nEvaluate whether memory node "${oldest.slug}" is still accurate given its age and domain.`,
319
- };
320
- }
321
-
322
- const target = nodes[0];
323
- return {
324
- type: 'task',
325
- slug: `staleness-check-${target.slug}-${Date.now().toString(36)}`,
326
- priority: 25,
327
- category: 'research-acquisition',
328
- target: target.slug,
329
- status: 'pending',
330
- created_by: 'scheduler-idle',
331
- reason: `Idle task: verify if fact "${target.title}" is still current.`,
332
- body: `## Objective\nThis fact was last accessed on ${target.last_accessed || 'unknown'}.\nDetermine if it is: STILL_VALID, POSSIBLY_STALE, or LIKELY_OUTDATED.\nIf stale, suggest a verification search query.`,
333
- };
334
- }
335
-
336
287
  /**
337
288
  * Pick a cluster of related nodes and create an inference task.
338
289
  */
@@ -499,107 +450,70 @@ export function createScheduler({ queueDir, vaultDir, sessionsDir }) {
499
450
  });
500
451
  }
501
452
 
502
- // Load pending research queue tasks
453
+ // Load pending research queue tasks.
454
+ //
455
+ // Research ends when it is answered: acquisition → deliberation → improvement →
456
+ // done. Nothing here re-opens finished work. (A `done` item used to be reset to
457
+ // a monitoring pass every hour, forever, and the expansion phase spawned three
458
+ // new topics per finished one — see docs/projects/.../RESEARCH_SYSTEM2_AUDIT.md.)
459
+ // Failed items retry after a cooldown, at most MAX_RESEARCH_ATTEMPTS times.
503
460
  try {
504
461
  const COOLDOWN_MS = process.env.RESEARCH_COOLDOWN_MS
505
462
  ? parseInt(process.env.RESEARCH_COOLDOWN_MS, 10)
506
463
  : 60 * 60 * 1000; // 1 hour default
507
464
 
508
- const allItems = loadQueue();
509
465
  let resetCount = 0;
510
-
511
- for (const item of allItems) {
512
- if (item.status === 'done' || item.status === 'failed') {
513
- const completedTime = new Date(item.completed_at || item.updated_at || 0).getTime();
514
- if (Date.now() - completedTime < COOLDOWN_MS) continue;
515
-
516
- // Empty "done" (no node, no conclusions) is NOT real research — re-acquire
517
- const emptyDone =
518
- item.status === 'done' &&
519
- !item.node_slug &&
520
- !(item.summary?.conclusions?.length > 0);
521
-
522
- // Successful done with real content: re-queue only for monitoring refresh, keep node
523
- // Failed or empty: force acquisition from scratch
524
- try {
525
- if (emptyDone || item.status === 'failed' || !item.node_slug) {
526
- updateQueueItem(item.id, {
527
- status: 'pending',
528
- research_phase: 'acquisition',
529
- notes: emptyDone
530
- ? 'Auto-reset empty done project → re-run acquisition (previous run produced no memory node)'
531
- : item.notes || null,
532
- });
533
- } else {
534
- // Has a real node — optional re-monitor after cooldown (don't wipe conclusions)
535
- updateQueueItem(item.id, {
536
- status: 'pending',
537
- research_phase: 'monitoring',
538
- });
539
- }
540
- resetCount++;
541
- } catch (updateErr) {
542
- logger.error({
543
- subsystem: 'scheduler',
544
- message: `Failed to reset research queue item ${item.id} to pending: ${updateErr.message}`,
545
- });
546
- }
466
+ for (const item of loadQueue()) {
467
+ if (item.status !== 'failed') continue;
468
+ if ((item.attempts || 0) >= MAX_RESEARCH_ATTEMPTS) continue;
469
+ const failedAt = new Date(item.updated_at || item.created_at || 0).getTime();
470
+ if (Date.now() - failedAt < COOLDOWN_MS) continue;
471
+ try {
472
+ updateQueueItem(item.id, {
473
+ status: 'pending',
474
+ research_phase: item.node_slug ? (item.research_phase || 'acquisition') : 'acquisition',
475
+ attempts: (item.attempts || 0) + 1,
476
+ });
477
+ resetCount++;
478
+ } catch (updateErr) {
479
+ logger.error({
480
+ subsystem: 'scheduler',
481
+ message: `Failed to retry research queue item ${item.id}: ${updateErr.message}`,
482
+ });
547
483
  }
548
484
  }
549
-
550
485
  if (resetCount > 0) {
551
486
  logger.info({
552
487
  subsystem: 'scheduler',
553
- message: `Auto-reset ${resetCount} research items after cooldown (empty→acquisition, failed→acquisition, done+node→monitoring).`,
488
+ message: `Retrying ${resetCount} failed research items (max ${MAX_RESEARCH_ATTEMPTS} attempts each).`,
554
489
  });
555
490
  }
556
491
 
492
+ // User requests run before autonomous background research; oldest first within each.
557
493
  const researchItems = loadQueue()
558
- .filter((i) => i.status === 'pending')
559
- .sort((a, b) => new Date(a.updated_at || a.created_at || 0).getTime() - new Date(b.updated_at || b.created_at || 0).getTime());
494
+ .filter((i) => i.status === 'pending' && RESEARCH_PHASES[i.research_phase || 'acquisition'])
495
+ .sort((a, b) => {
496
+ const ua = a.origin === 'autonomous' ? 1 : 0;
497
+ const ub = b.origin === 'autonomous' ? 1 : 0;
498
+ if (ua !== ub) return ua - ub;
499
+ return new Date(a.updated_at || a.created_at || 0).getTime() - new Date(b.updated_at || b.created_at || 0).getTime();
500
+ });
560
501
 
561
502
  for (let idx = 0; idx < researchItems.length; idx++) {
562
503
  const item = researchItems[idx];
563
- const dynamicPriority = 85 + (researchItems.length - idx);
564
504
  const phase = item.research_phase || 'acquisition';
565
-
566
- let category = 'proactive-research';
567
- let slug = `research-acquisition-${item.id}`;
568
- let reason = `Queued research project (Phase 1/5: Acquisition): ${item.topic}`;
569
- let body = `Run knowledge acquisition cycle for queued topic: ${item.topic}.\nNotes: ${item.notes || 'None'}`;
570
-
571
- if (phase === 'deliberation') {
572
- category = 'system2-deliberation';
573
- slug = `research-deliberation-${item.id}`;
574
- reason = `Queued research project (Phase 2/5: Deliberation): ${item.topic}`;
575
- body = `Run deep System 2 cognitive deliberation for topic: ${item.topic}.\nTarget node: ${item.node_slug || 'pending'}`;
576
- } else if (phase === 'improvement') {
577
- category = 'memory-maintenance';
578
- slug = `research-improvement-${item.id}`;
579
- reason = `Queued research project (Phase 3/5: Improvement): ${item.topic}`;
580
- body = `Run document improvement and formatting refinement for topic: ${item.topic}.\nTarget node: ${item.node_slug || 'pending'}`;
581
- } else if (phase === 'monitoring') {
582
- category = 'proactive-research';
583
- slug = `research-monitoring-${item.id}`;
584
- reason = `Queued research project (Phase 4/5: Monitoring): ${item.topic}`;
585
- body = `Identify reliable sources of ongoing information, news, feeds or release notes for: ${item.topic}.\nTarget node: ${item.node_slug || 'pending'}`;
586
- } else if (phase === 'expansion') {
587
- category = 'exploration';
588
- slug = `research-expansion-${item.id}`;
589
- reason = `Queued research project (Phase 5/5: Expansion): ${item.topic}`;
590
- body = `Discover adjacent research domains, brainstorm tangents, and auto-spawn follow-up tasks for: ${item.topic}.\nTarget node: ${item.node_slug || 'pending'}`;
591
- }
505
+ const spec = RESEARCH_PHASES[phase];
592
506
 
593
507
  queue.enqueue({
594
508
  type: 'task',
595
- slug,
596
- priority: dynamicPriority,
597
- category,
509
+ slug: `research-${phase}-${item.id}`,
510
+ priority: 85 + (researchItems.length - idx),
511
+ category: spec.category,
598
512
  target: item.topic,
599
513
  status: 'pending',
600
514
  created_by: 'research-queue',
601
- reason,
602
- body,
515
+ reason: `Queued research project (Phase ${spec.step}/${RESEARCH_PHASE_COUNT}: ${spec.label}): ${item.topic}`,
516
+ body: spec.body(item),
603
517
  _research_id: item.id,
604
518
  _research_phase: phase,
605
519
  _node_slug: item.node_slug,
@@ -1043,6 +1043,8 @@ export const MeshNodeSchema = z.object({
1043
1043
  /** Snapshot of host NICs (kinds are classified heuristically). */
1044
1044
  interfaces: z.array(NetworkInterfaceEntrySchema).optional(),
1045
1045
  lan_ip: z.string().nullable().optional(),
1046
+ /** Port of this node's brain HTTP service, for mesh service discovery. */
1047
+ brain_port: z.number().int().positive().max(65535).nullable().optional(),
1046
1048
  /**
1047
1049
  * Device I/O profile — entity variables for agent UI generation
1048
1050
  * (touchscreen, mic, speaker, screen, keyboard, …).
@@ -1064,6 +1066,125 @@ export const DaemonLeaderSchema = z.object({
1064
1066
  lease_ttl_seconds: z.number().int().positive().default(60),
1065
1067
  }).passthrough();
1066
1068
 
1069
+ /**
1070
+ * Install record for one plugin. Lives beside the plugin, in the vault of the
1071
+ * brain that owns its plugins directory: `system/plugins/<id>.md`.
1072
+ * Every field is a fact this node observed — there is deliberately no rating,
1073
+ * review, or popularity data anywhere in the plugin model.
1074
+ */
1075
+ export const PluginRecordSchema = z.object({
1076
+ type: z.literal('plugin_record'),
1077
+ title: z.string().min(1),
1078
+ description: z.string().min(1),
1079
+ timestamp: ssssDatetime(),
1080
+ plugin_id: z.string().regex(/^[a-z][a-z0-9-]{1,63}$/),
1081
+ version: z.string().min(1),
1082
+ scope: z.enum(['project', 'global']),
1083
+ /** Legacy private mesh sharing. Public sharing requires a separate opt-in. */
1084
+ shared: z.boolean().default(false),
1085
+ public_shared: z.boolean().default(false),
1086
+ source: z.object({
1087
+ kind: z.enum(['bundled', 'public', 'peer', 'git', 'local', 'link']),
1088
+ ref: z.string().min(1),
1089
+ peer_hostname: z.string().nullable().optional(),
1090
+ }).passthrough(),
1091
+ /** Content hash at install time (see plugin-bundle.mjs). */
1092
+ sha256: z.string().regex(/^[0-9a-f]{64}$/).nullable().optional(),
1093
+ installed_at: ssssDatetime(),
1094
+ /** Last minute-slot (YYYY-MM-DDTHH:MM) each scheduled task command ran in. */
1095
+ task_runs: z.record(z.string()).optional(),
1096
+ }).passthrough();
1097
+
1098
+ const SECRET_KEY_PATTERN = /^(.*_)?(secret|token|password|api[_-]?key|private[_-]?key|credentials|auth[_-]?header|bearer)$/i;
1099
+
1100
+ function hasProhibitedSecretData(obj) {
1101
+ if (!obj || typeof obj !== 'object') return false;
1102
+ for (const [key, value] of Object.entries(obj)) {
1103
+ if (SECRET_KEY_PATTERN.test(key) && value) return true;
1104
+ if (typeof value === 'object' && hasProhibitedSecretData(value)) return true;
1105
+ }
1106
+ return false;
1107
+ }
1108
+
1109
+ /**
1110
+ * App-local capability installation record.
1111
+ * Lives in the application's vault (e.g. `system/capabilities/<id>.md`).
1112
+ * Tracks installed version, source hash, adapter, granted access, generated files, and state.
1113
+ * Strictly prohibits raw credentials and secret values.
1114
+ */
1115
+ export const AppCapabilityInstallationSchema = z.object({
1116
+ type: z.literal('app_capability_installation'),
1117
+ title: z.string().min(1),
1118
+ description: z.string().min(1),
1119
+ timestamp: ssssDatetime(),
1120
+ capability_id: z.string().regex(/^[a-z][a-z0-9-]{1,63}$/),
1121
+ version: z.string().min(1),
1122
+ source_sha256: z.string().regex(/^[0-9a-f]{64}$/),
1123
+ adapter: z.string().min(1),
1124
+ status: z.enum(['installing', 'installed', 'failed', 'upgrading', 'upgrade_failed', 'uninstalled', 'repair_needed']),
1125
+ access_grants: z.array(z.string()).default([]),
1126
+ resources: z.record(z.any()).optional().default({}),
1127
+ files: z.array(z.object({
1128
+ path: z.string(),
1129
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
1130
+ size: z.number().int().nonnegative().optional()
1131
+ })).default([]),
1132
+ plan_hash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
1133
+ installed_at: ssssDatetime(),
1134
+ updated_at: ssssDatetime().optional(),
1135
+ error: z.string().nullable().optional()
1136
+ }).passthrough().superRefine((data, ctx) => {
1137
+ if (hasProhibitedSecretData(data.resources)) {
1138
+ ctx.addIssue({
1139
+ code: z.ZodIssueCode.custom,
1140
+ message: 'Security error: Prohibited credential or secret value detected in capability resources. Installation records must not store plaintext secrets.'
1141
+ });
1142
+ }
1143
+ });
1144
+
1145
+ /**
1146
+ * App-local access grant record.
1147
+ * Grants a capability or principal explicit access to specific scopes and resources.
1148
+ */
1149
+ export const AccessGrantSchema = z.object({
1150
+ type: z.literal('access_grant'),
1151
+ title: z.string().min(1),
1152
+ description: z.string().min(1),
1153
+ timestamp: ssssDatetime(),
1154
+ grantee: z.string().min(1),
1155
+ grantee_type: z.enum(['capability', 'agent', 'service', 'user']).default('capability'),
1156
+ scopes: z.array(z.string()).min(1),
1157
+ resources: z.array(z.string()).optional().default([]),
1158
+ status: z.enum(['active', 'revoked', 'expired']).default('active'),
1159
+ granted_at: ssssDatetime(),
1160
+ granted_by: z.string().min(1),
1161
+ expires_at: ssssDatetimeNullable().optional()
1162
+ }).passthrough();
1163
+
1164
+ /**
1165
+ * Canonical repo-layer skill configuration record.
1166
+ * Persisted as an SSSS document in the project vault (e.g. `system/skills/<id>.md`).
1167
+ * Controls gate commands, tier rules, and repo customizations.
1168
+ * Projected into `config.json` for skill scripts to read.
1169
+ */
1170
+ export const SkillConfigSchema = z.object({
1171
+ type: z.literal('skill_config'),
1172
+ title: z.string().min(1),
1173
+ description: z.string().min(1),
1174
+ timestamp: ssssDatetime(),
1175
+ skill_id: z.string().regex(/^[a-z][a-z0-9-]{1,63}$/),
1176
+ version: z.string().min(1),
1177
+ config: z.record(z.any()).default({}),
1178
+ tiers: z.record(z.any()).optional().default({}),
1179
+ gates: z.array(z.object({
1180
+ name: z.string(),
1181
+ command: z.string(),
1182
+ tier: z.string().optional()
1183
+ })).optional().default([]),
1184
+ created_at: ssssDatetime(),
1185
+ updated_at: ssssDatetime().optional()
1186
+ }).passthrough();
1187
+
1067
1188
  // ─── Schema Registry (§5 of the SSSS spec) ─────────────────────────────────
1068
1189
 
1069
1190
  /** Total Recall host-extension primitives; peer hosts are not required to register them. */
@@ -1073,6 +1194,10 @@ export const SSSS_HOST_EXTENSION_TYPES = [
1073
1194
  'notification_rule',
1074
1195
  'mesh_node',
1075
1196
  'daemon_leader',
1197
+ 'plugin_record',
1198
+ 'app_capability_installation',
1199
+ 'access_grant',
1200
+ 'skill_config',
1076
1201
  ];
1077
1202
 
1078
1203
  /** Map from SSSS `type` value to its Zod schema. Used by the operation validator. */
@@ -1082,6 +1207,10 @@ export const SSSS_SCHEMAS = {
1082
1207
  notification_rule: NotificationRuleSchema,
1083
1208
  mesh_node: MeshNodeSchema,
1084
1209
  daemon_leader: DaemonLeaderSchema,
1210
+ plugin_record: PluginRecordSchema,
1211
+ app_capability_installation: AppCapabilityInstallationSchema,
1212
+ access_grant: AccessGrantSchema,
1213
+ skill_config: SkillConfigSchema,
1085
1214
  memory: MemoryNodeSchema,
1086
1215
  conflict: ConflictRecordSchema,
1087
1216
  task: TaskSchema,
@@ -155,12 +155,17 @@ export async function semanticSearch(query, {
155
155
 
156
156
  // 2. Perform Semantic Search Channel (if embedding & index available)
157
157
  let semanticRanked = [];
158
+ // Raw cosine similarity per slug. RRF scores below are rank-based, so the top
159
+ // hit scores ~0.49 however unrelated it is; callers that must decide whether a
160
+ // hit is actually relevant (e.g. the browser extension's page recall) need this.
161
+ const similarityBySlug = new Map();
158
162
  if (queryEmbedding && vaultEntries.length > 0) {
159
163
  const store = new VectorStore();
160
164
  store.load(vaultIndex);
161
165
  const matches = store.search(queryEmbedding, filteredNodes.length, filteredSlugs, cosineSimilarity);
162
166
 
163
167
  semanticRanked = matches.map(({ slug, similarity }) => {
168
+ similarityBySlug.set(slug, similarity);
164
169
  const node = filteredNodes.find(n => n.slug === slug);
165
170
  const temporal = computeTemporalBoost(node);
166
171
  return { slug, score: similarity * temporal };
@@ -231,7 +236,13 @@ export async function semanticSearch(query, {
231
236
  // Slice top results
232
237
  for (const { node, score } of blendedRanked.slice(0, k)) {
233
238
  if (node) {
234
- results.push({ ...node, type: 'vault', score });
239
+ const similarity = similarityBySlug.get(node.slug);
240
+ results.push({
241
+ ...node,
242
+ type: 'vault',
243
+ score,
244
+ ...(similarity !== undefined && { similarity: Math.round(similarity * 1000) / 1000 }),
245
+ });
235
246
  }
236
247
  }
237
248
 
@@ -289,14 +289,18 @@ export async function maybeEnqueueIntegrationResearch(brainDir, key, meta = {},
289
289
  };
290
290
  }
291
291
 
292
- const { addToQueue } = await import('./research-queue.mjs');
293
- const item = addToQueue({
294
- topic: inference.topic,
295
- priority: inference.priority || 'low',
296
- notes: inference.notes || `AI-inferred product research for secret \`${key}\`.`,
297
- brainDir,
298
- });
299
- return { enqueued: true, item, inference };
292
+ // Nobody asked for this, so it goes through the autonomous gate and counts
293
+ // against the same daily budget as project gap research.
294
+ const { proposeAutonomousResearch } = await import('./research-gate.mjs');
295
+ const notes = inference.notes || `AI-inferred product research for secret \`${key}\`.`;
296
+ const { queued, skipped } = await proposeAutonomousResearch(
297
+ [{ topic: inference.topic, rationale: notes, priority: inference.priority || 'low' }],
298
+ { project: null, via: 'secret', brainDir, coverageCheck: opts.coverageCheck },
299
+ );
300
+ if (!queued.length) {
301
+ return { enqueued: false, skipped: skipped[0]?.reason || 'gate declined', inference };
302
+ }
303
+ return { enqueued: true, item: queued[0], inference };
300
304
  }
301
305
 
302
306
  /**
@@ -12,6 +12,7 @@ import crypto from 'crypto';
12
12
  import os from 'os';
13
13
  import YAML from 'yaml';
14
14
  import { encryptSecrets, decryptSecrets, encryptSecretsSync, decryptSecretsSync } from './crypto.mjs';
15
+ import { DEFAULT_KEYCHAIN_SERVICE, readKeychainPassword } from './secrets-keychain.mjs';
15
16
 
16
17
  const META_KEY = '__tr_secrets_meta';
17
18
 
@@ -39,8 +40,37 @@ export function resolveUsagePath(brainDir) {
39
40
  return path.join(brainDir, 'logs', 'usage.jsonl');
40
41
  }
41
42
 
42
- function secretsPassword() {
43
- return process.env.TR_SECRETS_PASSWORD || process.env.TR_MASTER_PASSWORD || null;
43
+ /**
44
+ * The master password: the environment first, then — on macOS — the Keychain
45
+ * entry `secret rekey` already maintains (service `total-recall-secrets`,
46
+ * overridable with TR_SECRETS_KEYCHAIN_SERVICE).
47
+ *
48
+ * Only an interactive shell profile used to read the Keychain, so a
49
+ * non-interactive process on the same Mac — a mesh `exec`, a release script —
50
+ * could not open a store its own user owns. Resolved once per process; the
51
+ * value is returned to the caller and never logged. TR_SECRETS_NO_KEYCHAIN=1
52
+ * disables the fallback.
53
+ */
54
+ let keychainPassword;
55
+ export function secretsPassword({ env = process.env, readKeychain = readTrKeychainPassword } = {}) {
56
+ const fromEnv = env.TR_SECRETS_PASSWORD || env.TR_MASTER_PASSWORD;
57
+ if (fromEnv) return fromEnv;
58
+ if (env.TR_SECRETS_NO_KEYCHAIN === '1') return null;
59
+ if (readKeychain !== readTrKeychainPassword) return readKeychain(env) || null;
60
+ if (keychainPassword === undefined) keychainPassword = readKeychain(env) || null;
61
+ return keychainPassword;
62
+ }
63
+
64
+ function readTrKeychainPassword(env) {
65
+ if (process.platform !== 'darwin') return null;
66
+ try {
67
+ return readKeychainPassword({
68
+ service: env.TR_SECRETS_KEYCHAIN_SERVICE || DEFAULT_KEYCHAIN_SERVICE,
69
+ account: env.USER || os.userInfo().username,
70
+ });
71
+ } catch {
72
+ return null;
73
+ }
44
74
  }
45
75
 
46
76
  /**
@@ -102,6 +102,7 @@ export function createSessionEntry({
102
102
  content,
103
103
  role = 'assistant',
104
104
  source = 'unknown',
105
+ cwd = null,
105
106
  }) {
106
107
  return {
107
108
  id: id || crypto.randomBytes(4).toString('hex'),
@@ -111,6 +112,9 @@ export function createSessionEntry({
111
112
  content: content || '',
112
113
  role,
113
114
  source,
115
+ // Working directory of the agent, when the tool records it. It is how a
116
+ // session is attributed to a project for project-scoped research.
117
+ ...(cwd ? { cwd } : {}),
114
118
  };
115
119
  }
116
120
 
@@ -229,6 +233,7 @@ export function parseClaudeCode(filePath) {
229
233
  content: content.slice(0, 5000), // cap individual entries
230
234
  role,
231
235
  source: 'claude-code',
236
+ cwd: typeof parsed.cwd === 'string' ? parsed.cwd : null,
232
237
  }),
233
238
  );
234
239
  }
@@ -282,6 +287,7 @@ export function parseCodex(filePath) {
282
287
  content: content.slice(0, 5000),
283
288
  role,
284
289
  source: 'codex',
290
+ cwd: typeof (parsed.cwd || parsed.payload?.cwd) === 'string' ? (parsed.cwd || parsed.payload.cwd) : null,
285
291
  }),
286
292
  );
287
293
  prevId = id;