total-recall-brain 3.43.3 → 3.45.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 (252) hide show
  1. package/bin/total-recall.mjs +6 -4
  2. package/docs/ARCHITECTURE.md +10 -7
  3. package/docs/reference/cli-reference.md +16 -9
  4. package/frontend/dist/assets/index-DACh1dhx.js +5613 -0
  5. package/frontend/dist/index.html +1 -1
  6. package/frontend/src/App.tsx +2 -2
  7. package/frontend/src/pages/PluginsPage.tsx +1 -1
  8. package/package.json +3 -3
  9. package/scaffold/.agent/skills/plugin/SKILL.md +7 -2
  10. package/scaffold/.agent/skills/plugin/evals/README.md +5 -0
  11. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +1 -0
  12. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +1 -0
  13. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +1 -0
  14. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +1 -0
  15. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +1 -0
  16. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +1 -0
  17. package/scaffold/.agent/skills/plugin/evals/cases/07-missing-reuse.mjs +11 -0
  18. package/scaffold/.agent/skills/plugin/evals/cases/08-unresolved-owner-command.mjs +12 -0
  19. package/scaffold/.agent/skills/plugin/evals/cases/09-missing-brick.mjs +12 -0
  20. package/scaffold/.agent/skills/plugin/prompts/01-function.md +4 -3
  21. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +1 -0
  22. package/scaffold/.agent/skills/plugin/references/surfaces.md +12 -2
  23. package/scaffold/.agent/skills/plugin/scripts/record.mjs +59 -2
  24. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +8 -0
  25. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +15 -14
  26. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -3
  27. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  28. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-gh-login.mjs +102 -0
  29. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +38 -2
  30. package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +157 -13
  31. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +12 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +161 -17
  33. package/scaffold/.agent/skills/total-recall/plugins/automations/BRICK.md +7 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/automations/LICENSE +21 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/automations/README.md +66 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/automations/adapters.mjs +231 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/automations/capability/index.mjs +16 -0
  38. package/scaffold/.agent/skills/total-recall/plugins/automations/cli.mjs +121 -0
  39. package/{src/core/plugin-cron.mjs → scaffold/.agent/skills/total-recall/plugins/automations/cron.mjs} +1 -3
  40. package/scaffold/.agent/skills/total-recall/plugins/automations/inventory.mjs +88 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/automations/package-lock.json +7661 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/automations/package.json +35 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/automations/plugin.json +167 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/automations/scheduler.mjs +246 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/DESIGN.md +42 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/automations.js +2 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/card.js +273 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/run-core.mjs +1 -1
  49. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +57 -5
  50. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report-data.mjs +40 -14
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report.mjs +15 -40
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +15 -14
  53. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -3
  54. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
  55. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +15 -14
  56. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -3
  57. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
  58. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +15 -14
  59. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -3
  60. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
  61. package/scaffold/.agent/skills/total-recall/plugins/git/README.md +112 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/cli.mjs +135 -23
  63. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package-lock.json +17 -16
  64. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package.json +5 -4
  65. package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +161 -0
  66. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/context.mjs +94 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/phase3-spec-support.mjs +50 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/runtime.mjs +40 -0
  69. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/spec-support.mjs +51 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/_support.mjs +195 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/auth.mjs +201 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/bisect.mjs +40 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/cli-meta.mjs +159 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +30 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit.mjs +46 -0
  76. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/fleet.mjs +156 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/force-push.mjs +58 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/group.mjs +119 -0
  79. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/handoff.mjs +59 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/message.mjs +22 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/resolve.mjs +60 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/rules.mjs +81 -0
  83. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/secret-firewall.mjs +66 -0
  84. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/undo.mjs +132 -0
  85. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/why.mjs +16 -0
  86. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/card.js +2 -2
  87. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +15 -14
  88. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -3
  89. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  90. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +59 -10
  91. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +1 -1
  92. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +18 -6
  93. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +160 -29
  94. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +15 -14
  95. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +4 -4
  96. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +231 -16
  97. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +19 -4
  98. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +104 -0
  99. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +3 -3
  100. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +101 -16
  101. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/control.mjs +145 -0
  102. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +54 -21
  103. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +40 -0
  104. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +68 -13
  105. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +55 -11
  106. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +64 -15
  107. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +29 -1
  108. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +1 -1
  109. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +249 -0
  110. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/jobs.mjs +84 -0
  111. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +6 -12
  112. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/maintenance.mjs +17 -0
  113. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/mcp-server.mjs +228 -0
  114. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +16 -8
  115. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +26 -2
  116. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +6 -1
  117. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +6 -2
  118. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +34 -3
  119. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reviews.mjs +25 -0
  120. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +28 -12
  121. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +28 -6
  122. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/claude.mjs +319 -0
  123. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/codex.mjs +387 -0
  124. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/result.mjs +67 -0
  125. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/resume.mjs +373 -0
  126. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/run.mjs +223 -0
  127. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session-jobs.mjs +72 -0
  128. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +6 -3
  129. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/termination.mjs +16 -12
  130. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +32 -1
  131. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +110 -0
  132. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/verify-ssss.mjs +3 -3
  133. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +597 -70
  134. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +76 -9
  135. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +5 -5
  136. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +3 -3
  137. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +47 -18
  138. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +15 -14
  139. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -3
  140. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
  141. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh-diagnostics.mjs +44 -0
  142. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +120 -113
  143. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +15 -14
  144. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -3
  145. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
  146. package/scaffold/.agent/skills/total-recall/plugins/ssss/assets/scaffold.json +5 -5
  147. package/scaffold/.agent/skills/total-recall/plugins/ssss/package-lock.json +6 -6
  148. package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +2 -2
  149. package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +1 -1
  150. package/scaffold/.agent/skills/total-recall/references/operations.md +1 -1
  151. package/src/cli/agent.mjs +1 -1
  152. package/src/cli/command.mjs +41 -2
  153. package/src/cli/connect.mjs +2 -29
  154. package/src/cli/context.mjs +1 -0
  155. package/src/cli/dream.mjs +93 -69
  156. package/src/cli/harness.mjs +4 -4
  157. package/src/cli/help.mjs +1 -1
  158. package/src/cli/ingest-okf.mjs +4 -4
  159. package/src/cli/init.mjs +0 -25
  160. package/src/cli/lessons.mjs +51 -0
  161. package/src/cli/memory-plugin.mjs +1 -1
  162. package/src/cli/plugin/copies.mjs +64 -14
  163. package/src/cli/plugin/create.mjs +6 -7
  164. package/src/cli/plugin/index.mjs +11 -7
  165. package/src/cli/plugin/install.mjs +10 -15
  166. package/src/cli/plugin/remove.mjs +1 -1
  167. package/src/cli/plugin/search.mjs +2 -2
  168. package/src/cli/preflight.mjs +19 -6
  169. package/src/cli/proposals.mjs +1 -1
  170. package/src/cli/recall.mjs +66 -23
  171. package/src/cli/release.mjs +4 -1
  172. package/src/cli/remember.mjs +3 -14
  173. package/src/cli/server.mjs +18 -0
  174. package/src/cli/startup.mjs +1 -0
  175. package/src/cli/task.mjs +9 -2
  176. package/src/cli/update.mjs +10 -3
  177. package/src/core/access-ledger.mjs +78 -0
  178. package/src/core/agent-manager.mjs +2 -2
  179. package/src/core/automatic-brain-updates.mjs +11 -6
  180. package/src/core/brain-locate.mjs +15 -0
  181. package/src/core/brain-updates.mjs +42 -12
  182. package/src/core/claude-hooks.mjs +3 -1
  183. package/src/core/cleanup-task.mjs +15 -0
  184. package/src/core/cli-commands.mjs +2 -0
  185. package/src/core/cli-maintenance.mjs +4 -1
  186. package/src/core/cli-semantic-index.mjs +23 -3
  187. package/src/core/context-compactor.mjs +7 -2
  188. package/src/core/context-compiler.mjs +26 -1
  189. package/src/core/context-policy.mjs +7 -3
  190. package/src/core/daemon-control.mjs +22 -0
  191. package/src/core/daemon-loop.mjs +39 -62
  192. package/src/core/default-plugins.mjs +1 -1
  193. package/src/core/dream.mjs +33 -105
  194. package/src/core/ensure-dependencies.mjs +148 -0
  195. package/src/core/fast-recall.mjs +2 -2
  196. package/src/core/field-evaluation.mjs +23 -0
  197. package/src/core/gate-mode.mjs +1 -1
  198. package/src/core/global-brain-updates.mjs +1 -1
  199. package/src/core/harness-hooks.mjs +14 -2
  200. package/src/core/lesson-hooks.mjs +74 -0
  201. package/src/core/lesson-memory.mjs +24 -0
  202. package/src/core/lesson-sessions.mjs +52 -0
  203. package/src/core/lessons.mjs +96 -0
  204. package/src/core/mesh-brain-updates.mjs +2 -1
  205. package/src/core/meta-harness.mjs +2 -2
  206. package/src/core/optimizer.mjs +66 -165
  207. package/src/core/plugin-contracts/card.mjs +3 -0
  208. package/src/core/plugin-loader.mjs +12 -3
  209. package/src/core/plugin-runtime.mjs +40 -1
  210. package/src/core/plugin-store.mjs +35 -23
  211. package/src/core/post-mortem.mjs +52 -36
  212. package/src/core/preflight-gate.mjs +23 -2
  213. package/src/core/proposal-applier.mjs +31 -2
  214. package/src/core/repo-sync.mjs +4 -6
  215. package/src/core/required-plugins.mjs +26 -4
  216. package/src/core/rule-length.mjs +23 -0
  217. package/src/core/runtime.mjs +1 -1
  218. package/src/core/scheduler.mjs +35 -43
  219. package/src/core/schema.mjs +1 -2
  220. package/src/core/search.mjs +4 -3
  221. package/src/core/server-restart.mjs +128 -7
  222. package/src/core/server-service.mjs +148 -0
  223. package/src/core/session-watcher.mjs +19 -1
  224. package/src/core/skill-gap.mjs +16 -0
  225. package/src/core/ssss-kernel-bridge.mjs +33 -6
  226. package/src/core/startup-health.mjs +6 -1
  227. package/src/core/surface.mjs +68 -57
  228. package/src/core/task-context.mjs +9 -8
  229. package/src/core/task-executors.mjs +54 -106
  230. package/src/core/validated-write.mjs +5 -0
  231. package/src/core/vault-files.mjs +8 -2
  232. package/src/core/vault.mjs +1 -1
  233. package/src/core/vector-field.mjs +134 -192
  234. package/src/server/index.mjs +3 -1
  235. package/src/server/invoke-core-child.mjs +24 -0
  236. package/src/server/invoke-core-operations.mjs +102 -0
  237. package/src/server/route-manifest.json +0 -56
  238. package/src/server/routes/brains.mjs +0 -18
  239. package/src/server/routes/context.mjs +0 -41
  240. package/src/server/routes/invoke.mjs +26 -1
  241. package/src/server/routes/ssss.mjs +0 -169
  242. package/src/server/routes/system.mjs +0 -15
  243. package/src/server/routes/tasks.mjs +0 -26
  244. package/src/server/routes/vault.mjs +2 -23
  245. package/frontend/dist/assets/index-DD4qONvH.js +0 -5607
  246. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +0 -53
  247. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +0 -129
  248. package/src/core/append-log.mjs +0 -131
  249. package/src/core/plugin-tasks.mjs +0 -173
  250. package/templates/onboarding-interview.md +0 -82
  251. /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/LICENSE +0 -0
  252. /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/DESIGN.md +0 -0
@@ -1,7 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import crypto from 'crypto';
5
4
  import matter from './frontmatter.mjs';
6
5
  import { writeNode, walkMd } from './vault.mjs';
7
6
  import { updateNodeInPlace } from './validated-write.mjs';
@@ -127,7 +126,7 @@ export function autoPruneStorage(brainDir, vaultDir, conflictsDir) {
127
126
  // Retrieve node slugs of active research projects so we never prune their drafts in progress
128
127
  const activeResearchSlugs = new Set();
129
128
  try {
130
- const queue = loadQueue();
129
+ const queue = loadQueue(brainDir);
131
130
  for (const item of queue) {
132
131
  if (item.status === 'pending' || item.status === 'in_progress') {
133
132
  if (item.node_slug) {
@@ -152,6 +151,13 @@ export function autoPruneStorage(brainDir, vaultDir, conflictsDir) {
152
151
  if (activeResearchSlugs.has(baseName)) {
153
152
  continue;
154
153
  }
154
+ // Lesson drafts remain reviewable until promotion or explicit disposition.
155
+ if (dir.startsWith(inboxDir) && file.endsWith('.md')) {
156
+ try {
157
+ const { data } = matter(fs.readFileSync(fullPath, 'utf8'));
158
+ if (data.x_review_hold || data.tags?.includes('post-mortem') || data.tags?.includes('lesson-review')) continue;
159
+ } catch { continue; }
160
+ }
155
161
  if (now - stat.mtimeMs > maxAge) {
156
162
  fs.unlinkSync(fullPath);
157
163
  }
@@ -244,22 +250,14 @@ export function autoPruneStorage(brainDir, vaultDir, conflictsDir) {
244
250
 
245
251
  import {
246
252
  generateMemoryCleanupProposals,
247
- generateStaleKnowledgeRefreshProposals,
248
253
  evaluateProposalGate,
249
254
  dedupeProposals,
255
+ runSmartDecay,
250
256
  } from './optimizer.mjs';
251
257
  import { applyAcceptedProposals } from './proposal-applier.mjs';
252
258
 
253
259
  const DREAM_PROMOTION_THRESHOLD = 0.7;
254
260
 
255
- // Master switch for the stale-knowledge-refresh *ticket generator* (see PHASE 4).
256
- // Disabled 2026-08-01: it filed one .md per stale node every cycle (16,401
257
- // unread tickets at its peak). Its successor, which queued "Verify
258
- // still-current" research every cycle, was removed too (RESEARCH_SYSTEM2):
259
- // research runs only when a human asks or a project needs it. Stale nodes are
260
- // listed for a human by `total-recall proposals stale` / GET /api/proposals/stale.
261
- const ENABLE_STALE_KNOWLEDGE_REFRESH = false;
262
-
263
261
  /**
264
262
  * Phase 1: Light Sleep. Scan for modifications.
265
263
  */
@@ -298,6 +296,7 @@ export function loadCandidatesFromInbox(brainDirPath) {
298
296
  const { data, content } = matter(raw);
299
297
  if (!data || (!data.slug && !data.title && !String(content || '').trim())) continue;
300
298
  const status = data.status || 'draft';
299
+ if (data.x_review_hold === true) continue;
301
300
  if (status === 'archived' || status === 'active') continue;
302
301
  candidates.push({
303
302
  ...data,
@@ -319,91 +318,12 @@ export function loadCandidatesFromInbox(brainDirPath) {
319
318
  return candidates;
320
319
  }
321
320
 
322
- /**
323
- * Lightweight session → candidate extraction (deterministic heuristics).
324
- * User lines that signal durable preferences/rules become draft candidates.
325
- */
326
- export function loadCandidatesFromSessions(sessionsDir, { maxFiles = 8, maxCandidates = 20 } = {}) {
327
- const candidates = [];
328
- if (!fs.existsSync(sessionsDir)) return candidates;
329
-
330
- const signal =
331
- /\b(always|never|prefer|remember|must not|must|invariant|don't|do not|from now on)\b/i;
332
-
333
- let files = [];
334
- try {
335
- files = fs
336
- .readdirSync(sessionsDir)
337
- .filter((f) => f.endsWith('.jsonl'))
338
- .map((f) => ({
339
- name: f,
340
- mtime: fs.statSync(path.join(sessionsDir, f)).mtimeMs,
341
- }))
342
- .sort((a, b) => b.mtime - a.mtime)
343
- .slice(0, maxFiles);
344
- } catch {
345
- return candidates;
346
- }
347
-
348
- for (const file of files) {
349
- let lines;
350
- try {
351
- lines = fs.readFileSync(path.join(sessionsDir, file.name), 'utf8').split('\n').filter(Boolean);
352
- } catch {
353
- continue;
354
- }
355
- for (const line of lines) {
356
- if (candidates.length >= maxCandidates) return candidates;
357
- let entry;
358
- try {
359
- entry = JSON.parse(line);
360
- } catch {
361
- continue;
362
- }
363
- const content = entry.content || entry.text || '';
364
- if (!content || content.length < 24 || content.length > 2000) continue;
365
- if (entry.type === 'tool_call') continue;
366
- const role = entry.role;
367
- if (role && role !== 'user' && role !== 'human') continue;
368
- if (!signal.test(content)) continue;
369
-
370
- const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 10);
371
- candidates.push({
372
- slug: `session-extract-${hash}`,
373
- category: 'preferences',
374
- title: content.slice(0, 80).replace(/\s+/g, ' '),
375
- status: 'draft',
376
- confidence: 0.55,
377
- importance: 3,
378
- evidence_count: 1,
379
- modality: /must not|never|don't|do not/i.test(content) ? 'must_not' : 'should',
380
- subject: 'user',
381
- predicate: 'prefers',
382
- object: 'behavior',
383
- tags: ['session-extract', 'dream-rem'],
384
- source: {
385
- type: 'session',
386
- session_id: path.basename(file.name, '.jsonl'),
387
- evidence_count: 1,
388
- },
389
- body: content,
390
- });
391
- }
392
- }
393
- return candidates;
394
- }
395
-
396
- /**
397
- * Collect all REM candidates (inbox + sessions). Dedupe by slug.
398
- */
321
+ /** Collect reviewed inbox candidates. Session models run through lesson-review. */
399
322
  export function collectRemCandidates({ brainDirPath, sessionsDir }) {
400
323
  const bySlug = new Map();
401
324
  for (const c of loadCandidatesFromInbox(brainDirPath)) {
402
325
  bySlug.set(c.slug, c);
403
326
  }
404
- for (const c of loadCandidatesFromSessions(sessionsDir)) {
405
- if (!bySlug.has(c.slug)) bySlug.set(c.slug, c);
406
- }
407
327
  return [...bySlug.values()];
408
328
  }
409
329
 
@@ -444,9 +364,11 @@ export function evaluateCandidates(candidates, existingNodes, conflictsDir) {
444
364
  * Main Dream Cycle execution.
445
365
  */
446
366
  export async function runDreamCycle({
447
- vaultDir, skillsDir, derivedDir, conflictsDir, instructionsFile
367
+ vaultDir, skillsDir, derivedDir, conflictsDir, instructionsFile, runtimeConfig
448
368
  }) {
449
- const sessionsDir = path.join(brainDir, 'sessions');
369
+ const cycleBrainDir = path.dirname(vaultDir);
370
+ const sessionsDir = path.join(cycleBrainDir, 'sessions');
371
+ const errors = [];
450
372
 
451
373
  logger.info('dream', 'PHASE 0 — Session Ingestion (IDE Conversation Logs)');
452
374
  try {
@@ -462,7 +384,7 @@ export async function runDreamCycle({
462
384
  logger.info('dream', `Modified vault files: ${modified.length}`);
463
385
 
464
386
  const candidates = collectRemCandidates({
465
- brainDirPath: brainDir,
387
+ brainDirPath: cycleBrainDir,
466
388
  sessionsDir,
467
389
  });
468
390
  logger.info('dream', `REM candidates collected: ${candidates.length}`);
@@ -508,6 +430,7 @@ export async function runDreamCycle({
508
430
  await compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile });
509
431
  logger.info('dream', 'Surface recompiled successfully');
510
432
  } catch (err) {
433
+ errors.push(`Deep Sleep compile failed: ${err.message}`);
511
434
  logger.error('dream', `Deep Sleep compile failed: ${err.message}`);
512
435
  }
513
436
 
@@ -516,14 +439,16 @@ export async function runDreamCycle({
516
439
  try {
517
440
  const cleanupProposals = await generateMemoryCleanupProposals(vaultDir);
518
441
 
519
- // Stale-knowledge-refresh generation is DISABLED. It filed one ticket per
520
- // high-importance node untouched for 30 days — ~600 of them — and nothing
521
- // in this system ever reads or applies a proposal, so every one was write-
522
- // only noise. Flip to true to re-enable; the dedupe gate below keeps it
523
- // bounded at one open ticket per target if you do.
524
- const staleProposals = ENABLE_STALE_KNOWLEDGE_REFRESH
525
- ? await generateStaleKnowledgeRefreshProposals(vaultDir)
526
- : [];
442
+ // DECAY-01: memories the access ledger shows unused for the decay age are judged by the
443
+ // local reasoning runtime; the gate and applier below verify each one before it applies.
444
+ let decayProposals = [];
445
+ try {
446
+ decayProposals = await runSmartDecay(vaultDir, runtimeConfig, undefined, { derivedDir });
447
+ logger.info('dream', `Smart decay: ${decayProposals.length} proposal(s).`);
448
+ } catch (err) {
449
+ errors.push(`Smart decay failed: ${err.message}`);
450
+ logger.error('dream', `Smart decay failed: ${err.message}`);
451
+ }
527
452
 
528
453
  // The generators are pure functions of vault state, so every cycle produces
529
454
  // the SAME proposals as the last one — a node stale on Monday is still stale
@@ -531,7 +456,7 @@ export async function runDreamCycle({
531
456
  // random slugs; the global vault accumulated 16,401 proposals for 594 real
532
457
  // targets (~28 copies each, ~5k/day) until proposals were 95% of the vault.
533
458
  const { kept: allProposals, suppressed } = dedupeProposals(
534
- [...cleanupProposals, ...staleProposals],
459
+ [...cleanupProposals, ...decayProposals],
535
460
  vaultDir,
536
461
  );
537
462
  if (suppressed > 0) {
@@ -560,6 +485,7 @@ export async function runDreamCycle({
560
485
  logger.info('dream', `Applied ${applied.applied} proposals (${applied.superseded} superseded, ${applied.failed} failed).`);
561
486
  }
562
487
  } catch (err) {
488
+ errors.push(`Optimizer failed: ${err.message}`);
563
489
  logger.error('dream', `Optimizer failed: ${err.message}`);
564
490
  }
565
491
 
@@ -573,18 +499,20 @@ export async function runDreamCycle({
573
499
  `Proposals accepted: ${proposalCount}`,
574
500
  ]);
575
501
  } catch (err) {
502
+ errors.push(`Daily note write failed: ${err.message}`);
576
503
  logger.error('dream', `Daily note write failed: ${err.message}`);
577
504
  }
578
505
 
579
506
  logger.info('dream', 'PHASE 5 — Automatic Storage & Memory Pruning');
580
507
  try {
581
- autoPruneStorage(brainDir, vaultDir, conflictsDir);
508
+ autoPruneStorage(cycleBrainDir, vaultDir, conflictsDir);
582
509
  logger.info('dream', 'Automated VFS storage pruning completed successfully');
583
510
  } catch (err) {
511
+ errors.push(`Storage pruning failed: ${err.message}`);
584
512
  logger.error('dream', `Storage pruning failed: ${err.message}`);
585
513
  }
586
514
 
587
- return { status: 'success' };
515
+ return errors.length ? { status: 'failed', errors } : { status: 'success' };
588
516
  }
589
517
 
590
518
  // ─── Standalone Daemon Execution ────────────────────────────────────────────────
@@ -0,0 +1,148 @@
1
+ /** Restore a repository's locked dependencies before edit or test work. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ export const DEFAULT_INSTALL_TIMEOUT_MS = 300_000;
9
+ const MARKER = '.total-recall-install.json';
10
+ const LOCKFILES = [
11
+ ['npm', 'npm-shrinkwrap.json'], ['npm', 'package-lock.json'],
12
+ ['pnpm', 'pnpm-lock.yaml'], ['yarn', 'yarn.lock'],
13
+ ['bun', 'bun.lock'], ['bun', 'bun.lockb'],
14
+ ];
15
+ const pending = new Map();
16
+ // The package this module runs from. `npm ci` deletes node_modules before installing, so running
17
+ // it here breaks every total-recall command, the preflight gate included, for the whole install
18
+ // (2026-11-10: the release's checks and every agent tool call failed until a manual install).
19
+ const RUNNING_PACKAGE_ROOT = (() => {
20
+ try { return fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')); }
21
+ catch { return null; }
22
+ })();
23
+
24
+ /**
25
+ * npm records the tree it installed in node_modules/.package-lock.json. The tree matches the
26
+ * lockfile when every installed package has the locked version and every required locked package
27
+ * is installed (optional packages may be absent on this platform; workspace links are not copies).
28
+ */
29
+ export function npmTreeMatchesLock(root) {
30
+ let lock, installed;
31
+ try {
32
+ lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')).packages;
33
+ installed = JSON.parse(fs.readFileSync(path.join(root, 'node_modules', '.package-lock.json'), 'utf8')).packages;
34
+ } catch { return false; }
35
+ if (!lock || !installed) return false;
36
+ for (const [key, entry] of Object.entries(installed)) {
37
+ if (!key || entry.link) continue;
38
+ if (!lock[key] || lock[key].version !== entry.version) return false;
39
+ }
40
+ for (const [key, entry] of Object.entries(lock)) {
41
+ if (!key.startsWith('node_modules/') && !key.includes('/node_modules/')) continue;
42
+ if (entry.optional || entry.link || entry.peer) continue;
43
+ if (!installed[key]) return false;
44
+ }
45
+ return true;
46
+ }
47
+
48
+ const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
49
+
50
+ /** No shell, bounded output, and a deadline that also stops lifecycle-script children. */
51
+ function runInstall(command, args, { cwd, timeoutMs }) {
52
+ return new Promise(resolve => {
53
+ const child = spawn(command, args, {
54
+ cwd, env: { ...process.env, CI: 'true' }, detached: process.platform !== 'win32',
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ });
57
+ let output = '', timedOut = false, error = null;
58
+ const collect = chunk => { output += chunk.toString().slice(0, Math.max(0, 65536 - output.length)); };
59
+ child.stdout.on('data', collect);
60
+ child.stderr.on('data', collect);
61
+ const timer = setTimeout(() => {
62
+ timedOut = true;
63
+ try {
64
+ if (process.platform === 'win32') child.kill('SIGKILL');
65
+ else process.kill(-child.pid, 'SIGKILL');
66
+ } catch { /* the install already exited */ }
67
+ }, timeoutMs);
68
+ child.on('error', err => { error = err.message; });
69
+ child.on('close', (exitCode, signal) => {
70
+ clearTimeout(timer);
71
+ resolve({ exitCode, signal, timedOut, output, error });
72
+ });
73
+ });
74
+ }
75
+
76
+ async function ensure(root, { reason = 'work', timeoutMs = DEFAULT_INSTALL_TIMEOUT_MS, run = runInstall, packageRoot = RUNNING_PACKAGE_ROOT } = {}) {
77
+ const started = Date.now();
78
+ const base = { root, reason, manager: null, lockfile: null, command: null };
79
+ const result = (status, fields = {}) => ({ ...base, ok: status !== 'failed', status, durationMs: Date.now() - started, ...fields });
80
+ try {
81
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 600_000) throw new Error('Install timeout must be 1..600000 ms');
82
+ const locks = LOCKFILES.filter(([, file]) => fs.existsSync(path.join(root, file)));
83
+ if (!locks.length) return result('skipped', { trigger: 'no-lockfile' });
84
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
85
+ const declared = manifest.packageManager?.split('@')[0];
86
+ const managers = new Set(locks.map(([manager]) => manager));
87
+ const manager = declared || (managers.size === 1 ? locks[0][0] : null);
88
+ if (!manager || !managers.has(manager)) throw new Error('Lockfiles do not identify one package manager; set package.json packageManager to a manager with a lockfile');
89
+ const lockfile = locks.find(([name]) => name === manager)[1];
90
+ const lockPath = path.join(root, lockfile);
91
+ const lockHash = hash(lockPath);
92
+ const lockMtimeMs = fs.statSync(lockPath).mtimeMs;
93
+ const modernYarn = manager === 'yarn' && (/^yarn@(?:[2-9]|\d{2,})\./.test(manifest.packageManager || '') || /^__metadata:/m.test(fs.readFileSync(lockPath, 'utf8')));
94
+ const args = manager === 'npm' ? ['ci'] : ['install', modernYarn ? '--immutable' : '--frozen-lockfile'];
95
+ Object.assign(base, { manager, lockfile, command: [manager, ...args] });
96
+ const installDir = () => path.join(root, modernYarn && fs.existsSync(path.join(root, '.pnp.cjs')) ? '.yarn' : 'node_modules');
97
+ const markerPath = () => path.join(installDir(), MARKER);
98
+ let marker;
99
+ try { marker = JSON.parse(fs.readFileSync(markerPath(), 'utf8')); } catch { /* no trusted install */ }
100
+ const current = marker?.version === 1 && marker.manager === manager && marker.lockfile === lockfile
101
+ && marker.lockHash === lockHash && marker.lockMtimeMs === lockMtimeMs;
102
+ if (current) return result('current');
103
+ const writeMarker = () => {
104
+ fs.mkdirSync(installDir(), { recursive: true });
105
+ fs.writeFileSync(markerPath(), `${JSON.stringify({ version: 1, manager, lockfile, lockHash, lockMtimeMs, installedAt: new Date().toISOString() })}\n`);
106
+ };
107
+ // Installed by hand or by an earlier npm run: npm's own record says the tree is current.
108
+ if (manager === 'npm' && lockfile === 'package-lock.json' && npmTreeMatchesLock(root)) {
109
+ writeMarker();
110
+ return result('current', { trigger: 'npm-tree-record' });
111
+ }
112
+ // Never reinstall the package this process runs from: the install would delete the code
113
+ // that is running, and every other total-recall process with it.
114
+ if (packageRoot && root === packageRoot) {
115
+ return result('failed', { trigger: 'running-package', error: `${root} is the Total Recall package this command runs from and its dependencies do not match ${lockfile}. Run '${manager} install' there yourself; it is never reinstalled automatically.` });
116
+ }
117
+ const trigger = fs.existsSync(installDir()) ? 'stale' : 'missing';
118
+ // A failed reinstall must not leave an old marker that can certify a partial install.
119
+ fs.rmSync(markerPath(), { force: true });
120
+ const installed = await run(manager, args, { cwd: root, timeoutMs });
121
+ if (installed.error || installed.timedOut || installed.exitCode !== 0) {
122
+ return result('failed', { ...installed, trigger, error: installed.error || (installed.timedOut ? `Install timed out after ${timeoutMs} ms` : `Install exited ${installed.exitCode ?? installed.signal ?? 'without a status'}`) });
123
+ }
124
+ if (hash(lockPath) !== lockHash || fs.statSync(lockPath).mtimeMs !== lockMtimeMs) throw new Error('Lockfile changed during install; retry with the current lockfile');
125
+ fs.mkdirSync(installDir(), { recursive: true });
126
+ const stamp = { version: 1, manager, lockfile, lockHash, lockMtimeMs, installedAt: new Date().toISOString() };
127
+ fs.writeFileSync(markerPath(), `${JSON.stringify(stamp)}\n`);
128
+ return result('installed', { trigger });
129
+ } catch (err) {
130
+ return result('failed', { error: err.message });
131
+ }
132
+ }
133
+
134
+ /**
135
+ * @returns {Promise<{ok:boolean, status:string, root:string, reason:string, manager:string|null, lockfile:string|null, command:string[]|null, durationMs:number}>}
136
+ * `run` is a fixture seam. Production callers use the bounded installer above.
137
+ * Concurrent callers in this host process share one install for the same real root.
138
+ */
139
+ export async function ensureDependencies(root, options = {}) {
140
+ let resolved;
141
+ try { resolved = fs.realpathSync(root); }
142
+ catch (err) { return { ok: false, status: 'failed', root, reason: options.reason || 'work', manager: null, lockfile: null, command: null, durationMs: 0, error: err.message }; }
143
+ if (pending.has(resolved)) return pending.get(resolved);
144
+ const job = ensure(resolved, options);
145
+ pending.set(resolved, job);
146
+ try { return await job; }
147
+ finally { pending.delete(resolved); }
148
+ }
@@ -73,7 +73,7 @@ function readIndex(file) {
73
73
 
74
74
  /** Local retrieval never imports the whole-vault cache or a provider. */
75
75
  export function fastSearch(query, { derivedDir, vaultDir, top_k = 5, category = null,
76
- tags = null, modality = null, importance = null, priority = null, fullText = false } = {}) {
76
+ tags = null, modality = null, importance = null, priority = null, fullText = false, rankResults = null } = {}) {
77
77
  const started = performance.now();
78
78
  const stats = { mode: fullText ? 'local-full-text' : 'local-metadata', index: 'missing',
79
79
  freshness: 'index-snapshot; selected documents verified against canonical Markdown', hydrated_documents: 0, stale_documents: 0 };
@@ -114,7 +114,7 @@ export function fastSearch(query, { derivedDir, vaultDir, top_k = 5, category =
114
114
  candidates.sort((a, b) => b.score - a.score);
115
115
  stats.match_ms = performance.now() - started - stats.index_ms;
116
116
  const hydration = performance.now();
117
- for (const candidate of candidates) {
117
+ for (const candidate of rankResults ? rankResults(candidates) : candidates) {
118
118
  if (results.length >= Math.max(1, Math.min(20, top_k))) break;
119
119
  if (!vaultDir) { results.push(candidate); continue; }
120
120
  const relative = candidate.relative || (isSafeVaultName(candidate.category) && isSafeVaultName(candidate.slug) ? `${candidate.category}/${candidate.slug}.md` : null);
@@ -0,0 +1,23 @@
1
+ /** Paired local recall evaluation. The caller fixes the corpus, labels and query order. */
2
+ export async function evaluateField(cases, { search, topK = 5 } = {}) {
3
+ if (!Number.isInteger(topK) || topK < 1 || topK > 20) throw new Error('topK must be an integer from 1 to 20');
4
+ if (!Array.isArray(cases) || !cases.length || cases.some(entry =>
5
+ typeof entry?.query !== 'string' || !entry.query.trim() || !Array.isArray(entry.relevant) ||
6
+ !entry.relevant.length || entry.relevant.some(slug => typeof slug !== 'string' || !slug.trim()))) {
7
+ throw new Error('Queries must be a nonempty array of { query, relevant: [slug, ...] }');
8
+ }
9
+ const rows = [];
10
+ for (const entry of cases) {
11
+ const baseline = (await search(entry.query, false)).slice(0, topK).map(result => result.slug);
12
+ const field = (await search(entry.query, true)).slice(0, topK).map(result => result.slug);
13
+ const shared = new Set(baseline.filter(slug => field.includes(slug))).size;
14
+ rows.push({ query: entry.query, relevant: entry.relevant, baseline, field,
15
+ overlap: shared / topK,
16
+ baseline_hit: baseline.some(slug => entry.relevant.includes(slug)),
17
+ field_hit: field.some(slug => entry.relevant.includes(slug)) });
18
+ }
19
+ const average = key => rows.reduce((sum, row) => sum + Number(row[key]), 0) / rows.length;
20
+ return { mode: 'local', top_k: topK, query_count: rows.length,
21
+ top_k_overlap: average('overlap'), baseline_hit_rate: average('baseline_hit'),
22
+ field_hit_rate: average('field_hit'), default_enabled: false, queries: rows };
23
+ }
@@ -4,7 +4,7 @@
4
4
  * The preflight receipt is not one of these rules and has no mode.
5
5
  *
6
6
  * Settings live in `config/gate.json` in a brain:
7
- * { "default": "warn", "router": "enforce", "plugins": { "git-sentinel": "enforce" } }
7
+ * { "default": "warn", "router": "enforce", "plugins": { "git": "enforce" } }
8
8
  * The repository's brain wins over the machine's. This runs on every tool call, so it reads the
9
9
  * files directly, and an unreadable file or unknown value counts as unset: a bad settings file
10
10
  * must never be what blocks work.
@@ -55,5 +55,5 @@ export function updateGlobalBrainPackages({versions,exec=spawnSync,sourceUpdate=
55
55
  if(row.status==='failed')row.error=result.error?.message || result.stderr?.slice(-1000) || 'Global version verification failed';
56
56
  }
57
57
  }
58
- return {failed:results.filter(row=>row.status==='failed').length,results};
58
+ return {failed:results.filter(row=>row.status==='failed').length,results,root};
59
59
  }
@@ -15,7 +15,7 @@ export async function loadGateAdapters(projectRoot = process.cwd()) {
15
15
  }
16
16
 
17
17
  /** Never throws: an install must finish, and a failure must be reported. */
18
- export async function installHarnessHooks({ projectRoot, dryRun = false, log = (line) => console.error(line) } = {}) {
18
+ export async function installHarnessHooks({ projectRoot, home, dryRun = false, log = (line) => console.error(line) } = {}) {
19
19
  try {
20
20
  const { adapters, failures } = await loadGateAdapters(projectRoot);
21
21
  if (!adapters?.installAll) {
@@ -23,12 +23,18 @@ export async function installHarnessHooks({ projectRoot, dryRun = false, log = (
23
23
  log(` ✖ Harness hooks not installed: ${cause}`);
24
24
  return { ok: false, rows: [], error: cause };
25
25
  }
26
- const result = await adapters.installAll({ dryRun });
26
+ const result = await adapters.installAll({ dryRun, ...(home ? { home } : {}) });
27
+ const { installLessonHooks } = await import('./lesson-hooks.mjs');
28
+ for (const row of result.rows) {
29
+ try { row.lessons = installLessonHooks(row, { dryRun }); }
30
+ catch (error) { row.lessons = { status: 'failed', error: error.message }; result.ok = false; }
31
+ }
27
32
  const active = result.rows.filter((row) => row.status !== 'not-present');
28
33
  log(` ${result.ok ? '✔' : '✖'} Harness hooks: ${active.map((row) => `${row.harness} ${row.status}`).join(', ') || 'no harness on this machine'}`);
29
34
  for (const row of active) {
30
35
  const note = row.note || row.reason;
31
36
  if (note) log(` ${row.harness}: ${note}`);
37
+ if (row.lessons?.error || row.lessons?.note) log(` ${row.harness} lessons: ${row.lessons.error || row.lessons.note}`);
32
38
  }
33
39
  return result;
34
40
  } catch (err) {
@@ -50,6 +56,12 @@ export async function removeHarnessHooks({ projectRoot, dryRun = false, log = (l
50
56
  log(` ✖ Harness hooks not removed: ${cause}`);
51
57
  return { ok: false, rows: [], error: cause };
52
58
  }
59
+ const { installLessonHooks } = await import('./lesson-hooks.mjs');
60
+ // Find each owner's settings before removal can remove the path.
61
+ for (const adapter of adapters.list()) {
62
+ const existing = await adapter.install({ dryRun: true, remove: true });
63
+ if (existing.settings) installLessonHooks({ harness: adapter.id, settings: existing.settings, status: 'installed' }, { dryRun, remove: true });
64
+ }
53
65
  const result = await adapters.removeAll({ dryRun });
54
66
  const removed = result.rows.filter((row) => row.status === 'removed').map((row) => row.harness);
55
67
  log(` ${result.ok ? '✔' : '✖'} Harness hooks: ${removed.length ? `removed from ${removed.join(', ')}` : 'none present'}`);
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseDocument } from 'yaml';
4
+ import { atomicWrite } from './vault.mjs';
5
+
6
+ const managed = hook => String(hook?.command || '').includes('total-recall lessons ');
7
+ const command = (harness, end = false) => `total-recall lessons ${end ? 'session-end' : 'tick'} --harness ${harness} --global`;
8
+
9
+ /** Extend the existing installer's selected settings file. Preserve unrelated hooks. */
10
+ export function installLessonHooks(row, { dryRun = false, remove = false } = {}) {
11
+ const { harness, settings } = row;
12
+ if (!settings || ['not-present', 'failed', 'unsupported'].includes(row.status)) return { status: 'session-ingest' };
13
+ if (harness === 'hermes') {
14
+ const text = fs.existsSync(settings) ? fs.readFileSync(settings, 'utf8') : '';
15
+ const doc = parseDocument(text || '{}');
16
+ if (doc.errors.length) throw new Error(`Invalid Hermes hook settings: ${doc.errors[0].message}`);
17
+ for (const [event, end] of [['post_llm_call', false], ['on_session_finalize', true]]) {
18
+ const old = doc.getIn(['hooks', event])?.toJSON() || [];
19
+ if (!Array.isArray(old)) throw new Error(`Hermes ${event} hooks must be a list`);
20
+ const hooks = old.filter(hook => !managed(hook));
21
+ if (!remove) hooks.push({ command: command(harness, end), timeout: 10 });
22
+ doc.setIn(['hooks', event], hooks);
23
+ }
24
+ if (!dryRun) atomicWrite(settings, String(doc));
25
+ return { status: remove ? 'removed' : dryRun ? 'would-install' : 'installed', settings };
26
+ }
27
+ if (harness === 'pi') {
28
+ const file = path.join(path.dirname(settings), 'total-recall-lessons.ts');
29
+ if (!dryRun) {
30
+ if (remove) fs.rmSync(file, { force: true });
31
+ else atomicWrite(file, PI_LESSONS_EXTENSION);
32
+ }
33
+ return { status: remove ? 'removed' : dryRun ? 'would-install' : 'installed', settings: file };
34
+ }
35
+ const events = harness === 'cursor' ? [['stop', false], ['sessionEnd', true]]
36
+ : ['claude', 'codex', 'dsh', 'agy'].includes(harness) ? [['Stop', false], ['SessionEnd', true]] : null;
37
+ if (!events || !settings.endsWith('.json')) return { status: 'session-ingest', note: 'Native lesson hook requires the harness owner; use session-end ingest.' };
38
+ const text = fs.existsSync(settings) ? fs.readFileSync(settings, 'utf8') : '{}';
39
+ const config = JSON.parse(text);
40
+ const hooks = harness === 'agy' ? (config['total-recall-lessons'] ||= {}) : (config.hooks ||= {});
41
+ for (const [event, end] of events) {
42
+ const old = hooks[event] || [];
43
+ if (!Array.isArray(old)) throw new Error(`${harness} ${event} hooks must be a list`);
44
+ // Remove only our command even when a group contains another owner's hook.
45
+ hooks[event] = old.flatMap(group => {
46
+ if (Array.isArray(group.hooks)) {
47
+ const kept = group.hooks.filter(hook => !managed(hook));
48
+ return kept.length ? [{ ...group, hooks: kept }] : [];
49
+ }
50
+ return managed(group) ? [] : [group];
51
+ });
52
+ if (!remove) hooks[event].push(harness === 'cursor' ? { command: command(harness, end), timeout: 10 }
53
+ : { hooks: [{ type: 'command', command: command(harness, end), timeout: 10 }] });
54
+ }
55
+ if (!dryRun) atomicWrite(settings, JSON.stringify(config, null, 2) + '\n');
56
+ return { status: remove ? 'removed' : dryRun ? 'would-install' : 'installed', settings };
57
+ }
58
+
59
+ export const PI_LESSONS_EXTENSION = `import { spawn } from 'node:child_process';
60
+ export default function (pi) {
61
+ const review = (event, ctx) => new Promise((resolve) => {
62
+ const entries = ctx.sessionManager.getBranch().filter(e => e.type === 'message').map(e => e.message);
63
+ const child = spawn('total-recall', ['lessons', event, '--harness', 'pi', '--global'], { stdio: ['pipe', 'ignore', 'pipe'] });
64
+ child.on('error', error => { console.error('Total Recall lessons:', error.message); resolve(); });
65
+ child.stderr.on('data', data => console.error(String(data)));
66
+ child.stdin.on('error', () => {});
67
+ child.stdin.end(JSON.stringify({ session_id: ctx.sessionManager.getSessionId(), entries }));
68
+ const timer = setTimeout(() => child.kill('SIGKILL'), 10000);
69
+ child.on('close', () => { clearTimeout(timer); resolve(); });
70
+ });
71
+ pi.on('agent_end', (_event, ctx) => review('tick', ctx));
72
+ pi.on('session_shutdown', (_event, ctx) => review('session-end', ctx));
73
+ }
74
+ `;
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+ import crypto from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execFile } from 'node:child_process';
5
+ import { loadNodes } from './vault.mjs';
6
+
7
+ const cli = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin/total-recall.mjs');
8
+
9
+ /** Use remember's length, duplicate and validated-write guards, with a pinned brain. */
10
+ export function rememberLessonRule(content, { brainDir, title, sourceRef, hold = false, execute = execFile } = {}) {
11
+ if (typeof content !== 'string' || !content.trim()) throw new Error('Lesson rule has no content');
12
+ const agentDir = path.resolve(brainDir, '../..');
13
+ if (path.resolve(agentDir, 'skills', 'total-recall') !== path.resolve(brainDir)) throw new Error('Lesson remember requires a standard brain path');
14
+ const slug = `lesson-rule-${crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)}`;
15
+ const existing = loadNodes(path.join(brainDir, 'memory-vault')).find(node => node.slug === slug);
16
+ if (existing && existing.body?.trim() === content.trim()) return Promise.resolve({ slug, existing: true });
17
+ const args = [cli, 'remember', 'invariant', content, '--global', '--slug', slug,
18
+ '--status', hold ? 'draft' : 'active', '--tags', 'lesson-review',
19
+ ...(title ? ['--title', title] : [])];
20
+ return new Promise((resolve, reject) => {
21
+ execute(process.execPath, args, { env: { ...process.env, AGENT_DIR: agentDir, TR_SESSION_ID: sourceRef }, timeout: 120_000, maxBuffer: 1024 * 1024 },
22
+ (error, stdout, stderr) => error ? reject(new Error(`Lesson remember failed: ${stderr || error.message}`)) : resolve({ slug, stdout }));
23
+ });
24
+ }
@@ -0,0 +1,52 @@
1
+ import fs from 'node:fs';
2
+
3
+ const text = value => typeof value === 'string' ? value : Array.isArray(value)
4
+ ? value.map(block => block.text || (block.type === 'tool_result' ? text(block.content) : JSON.stringify(block))).join('\n')
5
+ : value == null ? '' : JSON.stringify(value);
6
+
7
+ /** Normalize native hook transcripts and the session-ingest format. Keep tool evidence. */
8
+ export function readLessonEntries(file) {
9
+ const records = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap(line => {
10
+ try { return [JSON.parse(line)]; } catch { return []; }
11
+ });
12
+ // Codex records messages both as event notifications and as response items.
13
+ const hasResponseMessages = records.some(row => row.type === 'response_item' && row.payload?.type === 'message');
14
+ return records.flatMap(row => {
15
+ let message = row.message || row;
16
+ if (row.type === 'response_item') {
17
+ message = row.payload || {};
18
+ if (message.type === 'function_call' || message.type === 'function_call_output') {
19
+ return [{ role: 'tool', content: text(message), ts: row.timestamp }];
20
+ }
21
+ if (message.type !== 'message') return [];
22
+ } else if (row.type === 'event_msg') {
23
+ if (hasResponseMessages) return [];
24
+ const payload = row.payload || {};
25
+ if (!['user_message', 'agent_message'].includes(payload.type)) return [];
26
+ message = { role: payload.type === 'user_message' ? 'user' : 'assistant', content: payload.message };
27
+ }
28
+ let role = message.role || row.role;
29
+ if (role === 'human') role = 'user';
30
+ if (!['user', 'assistant', 'tool'].includes(role)) return [];
31
+ const content = [text(message.content ?? message.text), ...(message.tool_calls ? [JSON.stringify(message.tool_calls)] : [])].filter(Boolean).join('\n');
32
+ if (!content.trim()) return [];
33
+ // Claude tool results have role user, but do not start a user turn.
34
+ if (Array.isArray(message.content) && message.content.every(block => block.type === 'tool_result')) role = 'tool';
35
+ return [{ role, content, ts: row.ts || row.timestamp, ...(row.cwd ? { cwd: row.cwd } : {}) }];
36
+ });
37
+ }
38
+
39
+ export function countLessonTurns(entries) {
40
+ return entries.filter(entry => entry.role === 'user').length;
41
+ }
42
+
43
+ export function selectLessonTurns(entries, { fromTurn = 1, toTurn = Infinity } = {}) {
44
+ if (!Number.isInteger(fromTurn) || fromTurn < 1 || !(toTurn === Infinity || Number.isInteger(toTurn)) || toTurn < fromTurn) {
45
+ throw new Error('Lesson turn range must be positive and ordered');
46
+ }
47
+ let turn = 0;
48
+ return entries.filter(entry => {
49
+ if (entry.role === 'user') turn++;
50
+ return turn >= fromTurn && turn <= toTurn;
51
+ });
52
+ }