@aitne/daemon 0.1.9 → 0.1.11

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 (333) hide show
  1. package/dist/adapters/adapter-watchdog.d.ts +70 -0
  2. package/dist/adapters/adapter-watchdog.js +115 -0
  3. package/dist/adapters/discord.d.ts +17 -1
  4. package/dist/adapters/discord.js +33 -0
  5. package/dist/adapters/notification-manager.d.ts +27 -1
  6. package/dist/adapters/notification-manager.js +54 -39
  7. package/dist/adapters/slack-adapter.d.ts +26 -1
  8. package/dist/adapters/slack-adapter.js +41 -0
  9. package/dist/adapters/telegram-adapter.d.ts +18 -1
  10. package/dist/adapters/telegram-adapter.js +41 -2
  11. package/dist/adapters/types.d.ts +20 -0
  12. package/dist/adapters/whatsapp-adapter.d.ts +26 -7
  13. package/dist/adapters/whatsapp-adapter.js +74 -21
  14. package/dist/api/env-writer.d.ts +1 -0
  15. package/dist/api/env-writer.js +17 -7
  16. package/dist/api/helpers/agent-errors-registry.d.ts +5 -5
  17. package/dist/api/helpers/agent-errors-registry.js +5 -5
  18. package/dist/api/routes/agent-schedule.js +5 -1
  19. package/dist/api/routes/agent.js +33 -12
  20. package/dist/api/routes/agents/index.js +75 -16
  21. package/dist/api/routes/agents/views.d.ts +37 -2
  22. package/dist/api/routes/agents/views.js +64 -2
  23. package/dist/api/routes/apple-calendar.js +4 -1
  24. package/dist/api/routes/background-task.d.ts +22 -0
  25. package/dist/api/routes/background-task.js +338 -0
  26. package/dist/api/routes/browser-history.js +9 -1
  27. package/dist/api/routes/calendar.js +12 -2
  28. package/dist/api/routes/context/path-resolve.js +6 -1
  29. package/dist/api/routes/context/permissions.js +12 -2
  30. package/dist/api/routes/context/snapshots.js +0 -3
  31. package/dist/api/routes/context/write.js +3 -17
  32. package/dist/api/routes/dashboard/config.js +58 -12
  33. package/dist/api/routes/dashboard/cost-approvals.js +66 -0
  34. package/dist/api/routes/dashboard/notifications.js +9 -9
  35. package/dist/api/routes/dashboard/oauth-google.js +5 -3
  36. package/dist/api/routes/feedback.d.ts +3 -0
  37. package/dist/api/routes/feedback.js +349 -0
  38. package/dist/api/routes/git.js +10 -3
  39. package/dist/api/routes/github.js +5 -1
  40. package/dist/api/routes/integrations/crud-patch.js +5 -1
  41. package/dist/api/routes/integrations-reconcile.js +2 -2
  42. package/dist/api/routes/mcp.js +65 -13
  43. package/dist/api/routes/notion.d.ts +1 -1
  44. package/dist/api/routes/observations.js +7 -7
  45. package/dist/api/routes/obsidian.d.ts +1 -1
  46. package/dist/api/routes/receipts.js +5 -1
  47. package/dist/api/routes/setup-migrate.js +1 -1
  48. package/dist/api/routes/setup.js +1 -1
  49. package/dist/api/routes/task-flows.d.ts +1 -1
  50. package/dist/api/routes/task-flows.js +1 -1
  51. package/dist/api/routes/tuning.d.ts +29 -0
  52. package/dist/api/routes/tuning.js +304 -0
  53. package/dist/api/server.d.ts +44 -16
  54. package/dist/api/server.js +12 -0
  55. package/dist/bootstrap/adapters.d.ts +19 -0
  56. package/dist/bootstrap/adapters.js +61 -0
  57. package/dist/bootstrap/api.d.ts +5 -3
  58. package/dist/bootstrap/api.js +45 -13
  59. package/dist/bootstrap/catchup.d.ts +1 -1
  60. package/dist/bootstrap/catchup.js +11 -11
  61. package/dist/bootstrap/event-pipeline.d.ts +11 -0
  62. package/dist/bootstrap/event-pipeline.js +246 -8
  63. package/dist/bootstrap/observers.js +9 -6
  64. package/dist/bootstrap/schedule-helpers.d.ts +104 -6
  65. package/dist/bootstrap/schedule-helpers.js +172 -19
  66. package/dist/config.js +32 -12
  67. package/dist/core/agent-core.d.ts +33 -1
  68. package/dist/core/agent-core.js +36 -1
  69. package/dist/core/agents/activity-scan-cadence.d.ts +103 -0
  70. package/dist/core/agents/activity-scan-cadence.js +127 -0
  71. package/dist/core/agents/agent-route-override.d.ts +53 -0
  72. package/dist/core/agents/agent-route-override.js +69 -0
  73. package/dist/core/agents/builtin-registry.d.ts +51 -14
  74. package/dist/core/agents/builtin-registry.js +92 -15
  75. package/dist/core/agents/config-gate-reconcile.d.ts +38 -0
  76. package/dist/core/agents/config-gate-reconcile.js +51 -0
  77. package/dist/core/agents/cron-substitute.d.ts +1 -1
  78. package/dist/core/agents/cron-substitute.js +1 -1
  79. package/dist/core/agents/custom-routine-migration.d.ts +60 -0
  80. package/dist/core/agents/custom-routine-migration.js +149 -0
  81. package/dist/core/agents/firing-blocked.d.ts +1 -1
  82. package/dist/core/agents/hourly-cadence.d.ts +102 -0
  83. package/dist/core/agents/hourly-cadence.js +126 -0
  84. package/dist/core/agents/loader-boot.js +23 -0
  85. package/dist/core/agents/loader.d.ts +19 -0
  86. package/dist/core/agents/loader.js +34 -2
  87. package/dist/core/agents/override-merge.d.ts +1 -1
  88. package/dist/core/agents/override-merge.js +9 -1
  89. package/dist/core/agents/recurrence-convert.d.ts +1 -1
  90. package/dist/core/agents/recurrence-convert.js +1 -1
  91. package/dist/core/agents/recurring-schedule-adapter.js +8 -0
  92. package/dist/core/alerts.js +6 -6
  93. package/dist/core/backends/auth-health-monitor.d.ts +2 -2
  94. package/dist/core/backends/auth-health-monitor.js +1 -1
  95. package/dist/core/backends/backend-router.d.ts +27 -1
  96. package/dist/core/backends/backend-router.js +165 -1
  97. package/dist/core/backends/claude-code-core.d.ts +71 -31
  98. package/dist/core/backends/claude-code-core.js +282 -54
  99. package/dist/core/backends/cli-quota-guards.d.ts +29 -1
  100. package/dist/core/backends/cli-quota-guards.js +40 -5
  101. package/dist/core/backends/codex-core.d.ts +6 -0
  102. package/dist/core/backends/codex-core.js +22 -6
  103. package/dist/core/backends/failure-spend.d.ts +58 -0
  104. package/dist/core/backends/failure-spend.js +137 -0
  105. package/dist/core/backends/gemini-cli-core.d.ts +6 -0
  106. package/dist/core/backends/gemini-cli-core.js +38 -6
  107. package/dist/core/backends/model-registry.d.ts +1 -1
  108. package/dist/core/backends/model-registry.js +4 -4
  109. package/dist/core/backends/opencode-core.d.ts +1 -1
  110. package/dist/core/backends/opencode-core.js +5 -5
  111. package/dist/core/backends/plan-presets.js +47 -18
  112. package/dist/core/bang-commands/commands-cost.js +3 -1
  113. package/dist/core/bang-commands/commands-report.js +4 -3
  114. package/dist/core/bang-commands/commands-research.js +4 -1
  115. package/dist/core/bang-commands/commands-revert-tuning.d.ts +18 -0
  116. package/dist/core/bang-commands/commands-revert-tuning.js +63 -0
  117. package/dist/core/bang-commands/commands-stop-start.js +3 -3
  118. package/dist/core/bang-commands/commands-task-control.d.ts +19 -0
  119. package/dist/core/bang-commands/commands-task-control.js +147 -0
  120. package/dist/core/bang-commands/commands-wiki.js +5 -5
  121. package/dist/core/bang-commands/index.d.ts +2 -0
  122. package/dist/core/bang-commands/index.js +12 -0
  123. package/dist/core/bang-commands/registry.d.ts +12 -0
  124. package/dist/core/browser-history/research-cluster-fanout.d.ts +28 -14
  125. package/dist/core/browser-history/research-cluster-fanout.js +39 -16
  126. package/dist/core/channel-timeline.d.ts +5 -1
  127. package/dist/core/channel-timeline.js +13 -0
  128. package/dist/core/context/index-reconciler.js +5 -2
  129. package/dist/core/context/policy-index-reconciler.d.ts +6 -4
  130. package/dist/core/context/policy-index-runner.js +25 -6
  131. package/dist/core/context-builder-calendar.js +10 -2
  132. package/dist/core/context-builder-conversation.d.ts +8 -1
  133. package/dist/core/context-builder-conversation.js +41 -7
  134. package/dist/core/context-builder-yesterday.js +4 -3
  135. package/dist/core/context-builder.d.ts +7 -2
  136. package/dist/core/context-builder.js +193 -5
  137. package/dist/core/context-file-serializer.d.ts +1 -1
  138. package/dist/core/context-file-serializer.js +1 -1
  139. package/dist/core/context-health.js +2 -2
  140. package/dist/core/context-paths.d.ts +11 -1
  141. package/dist/core/context-paths.js +17 -1
  142. package/dist/core/context-validation/prepare-write.js +1 -1
  143. package/dist/core/context-validation/routine-rulebook.d.ts +1 -1
  144. package/dist/core/context-vault-aliases.d.ts +0 -13
  145. package/dist/core/context-vault-aliases.js +37 -0
  146. package/dist/core/custom-routines.d.ts +99 -0
  147. package/dist/core/custom-routines.js +187 -0
  148. package/dist/core/daemon-api-cli.js +50 -1
  149. package/dist/core/day-boundary.d.ts +46 -0
  150. package/dist/core/day-boundary.js +40 -0
  151. package/dist/core/dispatcher-activity-scan.d.ts +221 -0
  152. package/dist/core/dispatcher-activity-scan.js +775 -0
  153. package/dist/core/dispatcher-error-handling.d.ts +6 -11
  154. package/dist/core/dispatcher-error-handling.js +38 -62
  155. package/dist/core/dispatcher-hourly-check.js +6 -1
  156. package/dist/core/dispatcher-message-handler.d.ts +10 -0
  157. package/dist/core/dispatcher-message-handler.js +24 -0
  158. package/dist/core/dispatcher-morning-routine.d.ts +6 -6
  159. package/dist/core/dispatcher-morning-routine.js +13 -13
  160. package/dist/core/dispatcher-result-processor.d.ts +33 -0
  161. package/dist/core/dispatcher-result-processor.js +167 -11
  162. package/dist/core/dispatcher-scheduled-background-task.d.ts +42 -0
  163. package/dist/core/dispatcher-scheduled-background-task.js +89 -0
  164. package/dist/core/dispatcher-scheduled-tasks.d.ts +104 -1
  165. package/dist/core/dispatcher-scheduled-tasks.js +480 -8
  166. package/dist/core/dispatcher-task-delivery.d.ts +105 -0
  167. package/dist/core/dispatcher-task-delivery.js +555 -0
  168. package/dist/core/dispatcher-types.d.ts +48 -9
  169. package/dist/core/dispatcher-types.js +3 -3
  170. package/dist/core/dispatcher.d.ts +112 -31
  171. package/dist/core/dispatcher.js +297 -60
  172. package/dist/core/dm-freshness-metrics.d.ts +1 -1
  173. package/dist/core/drift-effects.js +2 -2
  174. package/dist/core/feedback/consolidation-prep.d.ts +94 -0
  175. package/dist/core/feedback/consolidation-prep.js +254 -0
  176. package/dist/core/feedback/eviction-scorer.d.ts +81 -0
  177. package/dist/core/feedback/eviction-scorer.js +136 -0
  178. package/dist/core/feedback/lesson-format.d.ts +79 -0
  179. package/dist/core/feedback/lesson-format.js +199 -0
  180. package/dist/core/feedback/lesson-injection.d.ts +98 -0
  181. package/dist/core/feedback/lesson-injection.js +174 -0
  182. package/dist/core/feedback/lesson-merge.d.ts +51 -0
  183. package/dist/core/feedback/lesson-merge.js +88 -0
  184. package/dist/core/feedback/lesson-store-overview.d.ts +46 -0
  185. package/dist/core/feedback/lesson-store-overview.js +42 -0
  186. package/dist/core/feedback/promotion-gate.d.ts +69 -0
  187. package/dist/core/feedback/promotion-gate.js +117 -0
  188. package/dist/core/feedback/regeneralization-prep.d.ts +87 -0
  189. package/dist/core/feedback/regeneralization-prep.js +152 -0
  190. package/dist/core/feedback/scope-parser.d.ts +86 -0
  191. package/dist/core/feedback/scope-parser.js +141 -0
  192. package/dist/core/feedback/self-performance-prep.d.ts +186 -0
  193. package/dist/core/feedback/self-performance-prep.js +541 -0
  194. package/dist/core/feedback/tuning-actuator.d.ts +198 -0
  195. package/dist/core/feedback/tuning-actuator.js +432 -0
  196. package/dist/core/feedback/tuning-recommender.d.ts +247 -0
  197. package/dist/core/feedback/tuning-recommender.js +580 -0
  198. package/dist/core/feedback/tuning-revert-monitor.d.ts +90 -0
  199. package/dist/core/feedback/tuning-revert-monitor.js +213 -0
  200. package/dist/core/health-monitor.d.ts +6 -0
  201. package/dist/core/health-monitor.js +1 -1
  202. package/dist/core/injection-policy.d.ts +83 -1
  203. package/dist/core/injection-policy.js +61 -3
  204. package/dist/core/integration-main-backend.js +4 -0
  205. package/dist/core/management-md.d.ts +2 -2
  206. package/dist/core/management-md.js +51 -13
  207. package/dist/core/morning/orchestrator.d.ts +2 -2
  208. package/dist/core/morning/orchestrator.js +2 -2
  209. package/dist/core/notification-gate.d.ts +64 -0
  210. package/dist/core/notification-gate.js +51 -0
  211. package/dist/core/notification-rate-limit.d.ts +40 -0
  212. package/dist/core/notification-rate-limit.js +50 -0
  213. package/dist/core/policy-files.d.ts +1 -1
  214. package/dist/core/policy-files.js +2 -2
  215. package/dist/core/pre-pass-freshness.d.ts +4 -4
  216. package/dist/core/retention.d.ts +5 -0
  217. package/dist/core/retention.js +20 -4
  218. package/dist/core/review-context.d.ts +1 -1
  219. package/dist/core/review-context.js +10 -5
  220. package/dist/core/roadmap-write-lock.d.ts +2 -1
  221. package/dist/core/roadmap-write-lock.js +15 -10
  222. package/dist/core/routine-acquisition-plan.d.ts +47 -1
  223. package/dist/core/routine-acquisition-plan.js +78 -20
  224. package/dist/core/routine-fetch-window-retry.js +7 -4
  225. package/dist/core/routine-fetch-window-runner.d.ts +39 -3
  226. package/dist/core/routine-fetch-window-runner.js +264 -13
  227. package/dist/core/routine-windows.d.ts +2 -2
  228. package/dist/core/routine-windows.js +8 -5
  229. package/dist/core/scheduler.d.ts +175 -16
  230. package/dist/core/scheduler.js +559 -102
  231. package/dist/core/signal-detector.d.ts +51 -1
  232. package/dist/core/signal-detector.js +321 -24
  233. package/dist/core/skills-compiler-denied-tools.js +2 -2
  234. package/dist/core/skills-compiler-skill-index.d.ts +2 -2
  235. package/dist/core/skills-compiler-skill-index.js +2 -2
  236. package/dist/core/skills-compiler-variants.d.ts +1 -1
  237. package/dist/core/skills-compiler-variants.js +8 -0
  238. package/dist/core/skills-compiler.d.ts +29 -26
  239. package/dist/core/skills-compiler.js +117 -81
  240. package/dist/core/skills-manifest.d.ts +37 -0
  241. package/dist/core/skills-manifest.js +73 -2
  242. package/dist/core/sleep-inhibitor.d.ts +79 -0
  243. package/dist/core/sleep-inhibitor.js +132 -0
  244. package/dist/core/slim-system-prompt-loader.d.ts +77 -0
  245. package/dist/core/slim-system-prompt-loader.js +141 -0
  246. package/dist/core/spawn-gates.d.ts +126 -0
  247. package/dist/core/spawn-gates.js +180 -0
  248. package/dist/core/today-direct-writer.d.ts +60 -14
  249. package/dist/core/today-direct-writer.js +90 -13
  250. package/dist/core/today-write-lock.d.ts +4 -2
  251. package/dist/core/today-write-lock.js +30 -20
  252. package/dist/core/wake-detector.d.ts +55 -0
  253. package/dist/core/wake-detector.js +80 -0
  254. package/dist/core/wiki/compile-lock.d.ts +1 -1
  255. package/dist/core/wiki/compile-lock.js +1 -1
  256. package/dist/core/wiki/wiki-fts.js +13 -6
  257. package/dist/core/workdir.js +15 -6
  258. package/dist/db/activity-scan-signals.d.ts +77 -0
  259. package/dist/db/activity-scan-signals.js +378 -0
  260. package/dist/db/agents-store.d.ts +28 -0
  261. package/dist/db/agents-store.js +62 -0
  262. package/dist/db/background-task-clarifications-store.d.ts +81 -0
  263. package/dist/db/background-task-clarifications-store.js +152 -0
  264. package/dist/db/background-task-store.d.ts +207 -0
  265. package/dist/db/background-task-store.js +380 -0
  266. package/dist/db/browser-history-store.d.ts +39 -6
  267. package/dist/db/browser-history-store.js +51 -7
  268. package/dist/db/browser-task-clarifications-store.d.ts +12 -0
  269. package/dist/db/browser-task-clarifications-store.js +35 -5
  270. package/dist/db/browser-task-store.d.ts +3 -0
  271. package/dist/db/browser-task-store.js +29 -4
  272. package/dist/db/deferred-dm.d.ts +86 -0
  273. package/dist/db/deferred-dm.js +199 -0
  274. package/dist/db/feedback-signals-store.d.ts +77 -0
  275. package/dist/db/feedback-signals-store.js +144 -0
  276. package/dist/db/migrations.js +380 -0
  277. package/dist/db/observations.d.ts +2 -2
  278. package/dist/db/observations.js +3 -3
  279. package/dist/db/schema.js +260 -22
  280. package/dist/db/voice-transcripts-store.d.ts +1 -1
  281. package/dist/index.js +86 -29
  282. package/dist/messaging/browser-task-mcp-notifier.d.ts +12 -70
  283. package/dist/messaging/browser-task-mcp-notifier.js +30 -151
  284. package/dist/messaging/browser-task-screenshot-attachment.d.ts +15 -0
  285. package/dist/messaging/browser-task-screenshot-attachment.js +63 -0
  286. package/dist/observers/delegated-sync-worker.d.ts +6 -6
  287. package/dist/observers/delegated-sync-worker.js +10 -10
  288. package/dist/observers/git-delegated-cron.d.ts +1 -1
  289. package/dist/observers/git-delegated-cron.js +2 -2
  290. package/dist/observers/github-poller-classifier.d.ts +3 -3
  291. package/dist/observers/github-poller-classifier.js +3 -3
  292. package/dist/observers/imminent-event-scheduler.d.ts +1 -1
  293. package/dist/observers/imminent-event-scheduler.js +1 -1
  294. package/dist/observers/mail-poller.d.ts +1 -0
  295. package/dist/observers/mail-poller.js +42 -3
  296. package/dist/observers/observation-summarizer/summarizer-client.d.ts +2 -2
  297. package/dist/observers/observation-summarizer/summarizer-client.js +2 -2
  298. package/dist/observers/observation-summarizer/worker.d.ts +2 -2
  299. package/dist/observers/observation-summarizer/worker.js +4 -4
  300. package/dist/observers/obsidian-watcher.d.ts +1 -1
  301. package/dist/observers/obsidian-watcher.js +1 -1
  302. package/dist/safety/agent-write-tracker.d.ts +4 -4
  303. package/dist/safety/agent-write-tracker.js +4 -4
  304. package/dist/safety/always-disallowed.d.ts +1 -1
  305. package/dist/safety/always-disallowed.js +39 -0
  306. package/dist/safety/audit.d.ts +43 -5
  307. package/dist/safety/audit.js +86 -18
  308. package/dist/safety/risk-classifier.d.ts +6 -0
  309. package/dist/safety/risk-classifier.js +97 -18
  310. package/dist/scheduler/activity-scan-gate.d.ts +86 -0
  311. package/dist/scheduler/activity-scan-gate.js +132 -0
  312. package/dist/services/background-task/background-task-budget.d.ts +80 -0
  313. package/dist/services/background-task/background-task-budget.js +91 -0
  314. package/dist/services/background-task/background-task-driver.d.ts +105 -0
  315. package/dist/services/background-task/background-task-driver.js +416 -0
  316. package/dist/services/background-task/background-task-runner.d.ts +96 -0
  317. package/dist/services/background-task/background-task-runner.js +673 -0
  318. package/dist/services/background-task/background-task-tools.d.ts +84 -0
  319. package/dist/services/background-task/background-task-tools.js +247 -0
  320. package/dist/services/background-task/background-task-transition-events.d.ts +43 -0
  321. package/dist/services/background-task/background-task-transition-events.js +54 -0
  322. package/dist/services/browser-history/automation/egress-denylist.d.ts +1 -1
  323. package/dist/services/browser-history/automation/egress-denylist.js +34 -8
  324. package/dist/services/browser-history/lifecycle/platform.js +44 -2
  325. package/dist/services/browser-history/managed-chromium/sandbox-launcher.js +0 -1
  326. package/dist/services/browser-task/browser-task-runner.js +53 -8
  327. package/dist/services/mcp/probe.js +30 -8
  328. package/dist/services/observations-batch.d.ts +1 -1
  329. package/dist/services/observations-batch.js +2 -2
  330. package/dist/settings/runtime-settings.d.ts +45 -12
  331. package/dist/settings/runtime-settings.js +215 -40
  332. package/dist/settings/settings-store.js +11 -3
  333. package/package.json +4 -4
@@ -51,7 +51,7 @@ export interface DmFreshnessAggregate {
51
51
  * `agent_log_lag_minutes=0` by construction (the snapshot is built at
52
52
  * dispatch time), so including them would drag the percentile toward 0
53
53
  * and hide the cohort the plan §6 acceptance threshold targets
54
- * ("p95 ≤ 60 — i.e. resumed turns are typically within an hourly_check
54
+ * ("p95 ≤ 60 — i.e. resumed turns are typically within an activity_scan
55
55
  * cadence of session start"). When `resumedTurns === 0`, both
56
56
  * percentiles are 0 — there is no lag to report.
57
57
  */
@@ -224,7 +224,7 @@ function recordCoalescedObservation(db, params) {
224
224
  // novelty_score populated. Without this, coalesced inserts only get
225
225
  // summarized via the daemon-startup reclaim sweep — a row that lands
226
226
  // mid-run sits at `summary_status='pending'` forever and the
227
- // hourly_check skill is forced into the legacy fetch-on-doubt path.
227
+ // activity_scan skill is forced into the legacy fetch-on-doubt path.
228
228
  if (inserted)
229
229
  notifyObservationSummarizer(inserted.id);
230
230
  return 1;
@@ -257,7 +257,7 @@ function recordCoalescedObservation(db, params) {
257
257
  // summary describes obsolete content. Reset the summarizer-owned columns
258
258
  // to mirror `recordObservation`'s UPSERT on payload change — without
259
259
  // this, `summary_text` / `novelty_score` linger from the prior payload
260
- // and the hourly_check skill consumes a stale summary as if it were
260
+ // and the activity_scan skill consumes a stale summary as if it were
261
261
  // current (`summary_status='done'` with `summaryStale=false`).
262
262
  db.prepare(`UPDATE observations
263
263
  SET change_type = ?,
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Feedback Learning Loop — consolidation pre-step (FEEDBACK_LEARNING_LOOP_DESIGN.md §4).
3
+ *
4
+ * The daemon-side, deterministic half of Stage 2. On the evening-review tick it
5
+ * reads unconsumed `feedback_signals`, groups them by `(scope_type, scope_ref)`,
6
+ * pre-computes each candidate's weighted evidence + promotion verdict and each
7
+ * lessons file's eviction ranking + headroom, and emits a `<feedback_worksheet>`
8
+ * block — exactly as `<journal_skeleton>` / `harvestForGate` blocks are
9
+ * daemon-prepared today. The LLM step then does only the *semantic* work
10
+ * (intent-match merge, contradiction detection, phrasing) and writes via
11
+ * `PATCH /api/context/policies/agent-lessons`, then consumes the worksheet's ids.
12
+ *
13
+ * Two layers, mirroring `journal-skeleton-builder.ts`:
14
+ * - `gatherFeedbackWorksheetScopes(db, …)` — the single DB read (side-effect
15
+ * free); groups pending signals by scope. Cost scales with feedback volume,
16
+ * not agent count (the `idx_feedback_unconsumed` partial index).
17
+ * - `buildFeedbackWorksheet(scopes, …)` — pure markdown/XML composer. Every
18
+ * output byte is a deterministic function of its inputs; the caller supplies
19
+ * each lessons file's current contents so this stays I/O-free and 100%
20
+ * coverable.
21
+ *
22
+ * Phase 2 stored `user` + `agent`; Phase 4 added `agent:<slug>` (the evening-review
23
+ * pre-step now requests it). This module already rendered any lessons scope
24
+ * generically, so Phase 4 was wiring (`scopeTypes` + task-flow), not new logic here.
25
+ */
26
+ import type Database from "better-sqlite3";
27
+ import { type FeedbackScopeType, type FeedbackSignalRow } from "../../db/feedback-signals-store.js";
28
+ import { type CanonicalScope } from "./scope-parser.js";
29
+ /** Fixed entry caps (§6 table) — config carries only the byte caps. */
30
+ export declare const GLOBAL_LESSON_ENTRY_CAP = 40;
31
+ export declare const PER_AGENT_LESSON_ENTRY_CAP = 20;
32
+ export interface WorksheetScopeGroup {
33
+ scope: CanonicalScope;
34
+ signals: FeedbackSignalRow[];
35
+ }
36
+ /**
37
+ * Read unconsumed signals for the requested scope types and group them by
38
+ * canonical scope. Each scope type is queried independently (oldest-first
39
+ * within the type) so the per-pass row budget applies *per type*. A single
40
+ * global `LIMIT` over `created_at ASC` would let a backlog of unconsumed
41
+ * `agent_slug` rows occupy the oldest-N window and silently starve the
42
+ * `user`/`agent` scopes — the per-type fetch caps each scope type
43
+ * independently so a busy agent can't crowd out the others. Groups come back in
44
+ * `scopeTypes` order; rows whose `(scope_type, scope_ref)` can't be parsed
45
+ * (defensive — the route + behavioral sink only write valid pairs) are skipped
46
+ * so a bad row never breaks the pass.
47
+ */
48
+ export declare function gatherFeedbackWorksheetScopes(db: Database.Database, opts: {
49
+ scopeTypes: ReadonlyArray<FeedbackScopeType>;
50
+ limit?: number;
51
+ }): WorksheetScopeGroup[];
52
+ /** Resolve per-scope byte/entry caps; `null` for raw (user) + unstored scopes. */
53
+ export declare function lessonCapsForScope(scope: CanonicalScope, byteCaps: {
54
+ global: number;
55
+ perAgent: number;
56
+ }): {
57
+ capBytes: number;
58
+ maxEntries: number;
59
+ } | null;
60
+ export interface WorksheetScopeInput {
61
+ scope: CanonicalScope;
62
+ signals: FeedbackSignalRow[];
63
+ /** Current lessons-store file contents (lessons scopes), else null. */
64
+ existingFileMd: string | null;
65
+ /** Byte/entry caps for lessons scopes; null for raw (user) scopes. */
66
+ caps: {
67
+ capBytes: number;
68
+ maxEntries: number;
69
+ } | null;
70
+ }
71
+ export interface BuildWorksheetOptions {
72
+ promotionThreshold: number;
73
+ nowIso: string;
74
+ recencyHalfLifeDays?: number;
75
+ /**
76
+ * Staleness horizon in days (`feedbackLessonStaleDays`, §4 step 7). An
77
+ * existing lesson whose `last=` predates `now − staleDays` and is not a
78
+ * `constraint` is flagged `stale="true"` so the LLM prunes it in the rebuild.
79
+ * Omitted ⇒ nothing is flagged stale (no time-based prune this pass).
80
+ */
81
+ staleDays?: number;
82
+ }
83
+ export interface WorksheetResult {
84
+ /** `<feedback_worksheet>…</feedback_worksheet>` block for verbatim injection. */
85
+ block: string;
86
+ /** Every surfaced signal id — the exact consume set (§4 step 6). */
87
+ signalIds: number[];
88
+ scopeCount: number;
89
+ }
90
+ /**
91
+ * Compose the `<feedback_worksheet>` block. Returns `null` when there are no
92
+ * signals at all (the caller then stamps nothing — no empty block in the prompt).
93
+ */
94
+ export declare function buildFeedbackWorksheet(scopes: ReadonlyArray<WorksheetScopeInput>, opts: BuildWorksheetOptions): WorksheetResult | null;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Feedback Learning Loop — consolidation pre-step (FEEDBACK_LEARNING_LOOP_DESIGN.md §4).
3
+ *
4
+ * The daemon-side, deterministic half of Stage 2. On the evening-review tick it
5
+ * reads unconsumed `feedback_signals`, groups them by `(scope_type, scope_ref)`,
6
+ * pre-computes each candidate's weighted evidence + promotion verdict and each
7
+ * lessons file's eviction ranking + headroom, and emits a `<feedback_worksheet>`
8
+ * block — exactly as `<journal_skeleton>` / `harvestForGate` blocks are
9
+ * daemon-prepared today. The LLM step then does only the *semantic* work
10
+ * (intent-match merge, contradiction detection, phrasing) and writes via
11
+ * `PATCH /api/context/policies/agent-lessons`, then consumes the worksheet's ids.
12
+ *
13
+ * Two layers, mirroring `journal-skeleton-builder.ts`:
14
+ * - `gatherFeedbackWorksheetScopes(db, …)` — the single DB read (side-effect
15
+ * free); groups pending signals by scope. Cost scales with feedback volume,
16
+ * not agent count (the `idx_feedback_unconsumed` partial index).
17
+ * - `buildFeedbackWorksheet(scopes, …)` — pure markdown/XML composer. Every
18
+ * output byte is a deterministic function of its inputs; the caller supplies
19
+ * each lessons file's current contents so this stays I/O-free and 100%
20
+ * coverable.
21
+ *
22
+ * Phase 2 stored `user` + `agent`; Phase 4 added `agent:<slug>` (the evening-review
23
+ * pre-step now requests it). This module already rendered any lessons scope
24
+ * generically, so Phase 4 was wiring (`scopeTypes` + task-flow), not new logic here.
25
+ */
26
+ import { getPendingFeedbackSignals, } from "../../db/feedback-signals-store.js";
27
+ import { extractMarkdownSection, LESSON_KINDS, parseLessonsSection, } from "./lesson-format.js";
28
+ import { evaluatePromotion } from "./promotion-gate.js";
29
+ import { enforceCaps, scoreLesson, isLessonStale, DEFAULT_RECENCY_HALFLIFE_DAYS, } from "./eviction-scorer.js";
30
+ import { groupSignalsBySummary } from "./lesson-merge.js";
31
+ import { formatScope, parseScope, scopeKey, scopeSectionSlug, scopeStoreFile, } from "./scope-parser.js";
32
+ /** Fixed entry caps (§6 table) — config carries only the byte caps. */
33
+ export const GLOBAL_LESSON_ENTRY_CAP = 40;
34
+ export const PER_AGENT_LESSON_ENTRY_CAP = 20;
35
+ /** Default ceiling on signals pulled per pass (store caps the query at 500). */
36
+ const DEFAULT_SIGNAL_LIMIT = 400;
37
+ /**
38
+ * Read unconsumed signals for the requested scope types and group them by
39
+ * canonical scope. Each scope type is queried independently (oldest-first
40
+ * within the type) so the per-pass row budget applies *per type*. A single
41
+ * global `LIMIT` over `created_at ASC` would let a backlog of unconsumed
42
+ * `agent_slug` rows occupy the oldest-N window and silently starve the
43
+ * `user`/`agent` scopes — the per-type fetch caps each scope type
44
+ * independently so a busy agent can't crowd out the others. Groups come back in
45
+ * `scopeTypes` order; rows whose `(scope_type, scope_ref)` can't be parsed
46
+ * (defensive — the route + behavioral sink only write valid pairs) are skipped
47
+ * so a bad row never breaks the pass.
48
+ */
49
+ export function gatherFeedbackWorksheetScopes(db, opts) {
50
+ const limit = opts.limit ?? DEFAULT_SIGNAL_LIMIT;
51
+ const order = [];
52
+ const byKey = new Map();
53
+ for (const scopeType of opts.scopeTypes) {
54
+ const rows = getPendingFeedbackSignals(db, { scopeType, limit });
55
+ for (const row of rows) {
56
+ const scope = parseScope(row.scope_type, row.scope_ref);
57
+ if (!scope)
58
+ continue;
59
+ const key = scopeKey(scope);
60
+ let group = byKey.get(key);
61
+ if (!group) {
62
+ group = { scope, signals: [] };
63
+ byKey.set(key, group);
64
+ order.push(key);
65
+ }
66
+ group.signals.push(row);
67
+ }
68
+ }
69
+ return order.map((key) => byKey.get(key));
70
+ }
71
+ /** Resolve per-scope byte/entry caps; `null` for raw (user) + unstored scopes. */
72
+ export function lessonCapsForScope(scope, byteCaps) {
73
+ if (scope.kind === "agent") {
74
+ return { capBytes: byteCaps.global, maxEntries: GLOBAL_LESSON_ENTRY_CAP };
75
+ }
76
+ if (scope.kind === "agent_slug") {
77
+ return {
78
+ capBytes: byteCaps.perAgent,
79
+ maxEntries: PER_AGENT_LESSON_ENTRY_CAP,
80
+ };
81
+ }
82
+ return null;
83
+ }
84
+ function xmlEscape(value) {
85
+ return value
86
+ .replace(/&/g, "&amp;")
87
+ .replace(/</g, "&lt;")
88
+ .replace(/>/g, "&gt;")
89
+ .replace(/"/g, "&quot;");
90
+ }
91
+ function round2(value) {
92
+ return (Math.round(value * 100) / 100).toFixed(2);
93
+ }
94
+ /**
95
+ * `store=` attribute for a scope. Stored scopes (user/agent/agent_slug) resolve
96
+ * to a path; v2 scopes surfaced raw (channel/task/integration, not yet stored)
97
+ * render an empty string so the LLM treats them as advisory-only.
98
+ */
99
+ function storeFileAttr(scope) {
100
+ return scopeStoreFile(scope) ?? "";
101
+ }
102
+ /** Collapse a one-line excerpt of a signal/lesson for an XML text node.
103
+ * The clip strips a trailing lone high surrogate so cutting through an
104
+ * astral char (emoji) can't leave a U+FFFD in the worksheet. */
105
+ function inline(text, max = 300) {
106
+ const flat = text.replace(/\s+/g, " ").trim();
107
+ const clipped = flat.length > max
108
+ ? `${flat.slice(0, max - 1).replace(/[\uD800-\uDBFF]$/, "")}…`
109
+ : flat;
110
+ return xmlEscape(clipped);
111
+ }
112
+ /** Authority ranking for picking a candidate's representative `src=` trailer. */
113
+ const SOURCE_AUTHORITY = {
114
+ explicit: 3,
115
+ self_critique: 2,
116
+ behavioral: 1,
117
+ };
118
+ /** The strongest source across a candidate's contributing signals. */
119
+ function dominantSource(rows) {
120
+ return rows.reduce((best, row) => SOURCE_AUTHORITY[row.source] > SOURCE_AUTHORITY[best] ? row.source : best, "behavioral");
121
+ }
122
+ /** Read a stated lesson `kind` out of a signal's `evidence_json` (the route
123
+ * stores an explicit/self_critique POST's `kind` there), tolerating malformed
124
+ * JSON. */
125
+ function evidenceKind(json) {
126
+ if (!json)
127
+ return null;
128
+ try {
129
+ const parsed = JSON.parse(json);
130
+ return typeof parsed?.kind === "string" && LESSON_KINDS.has(parsed.kind)
131
+ ? parsed.kind
132
+ : null;
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ /**
139
+ * Best-effort lesson `kind` for a candidate so the LLM doesn't have to guess
140
+ * the trailer it's told to copy "from the candidate" (task-flow Step 4a): an
141
+ * explicit/self_critique POST's stated `kind` wins, else a `correction`
142
+ * valence maps to `correction`, else `null` (the LLM infers from the prose).
143
+ */
144
+ function candidateKind(rows) {
145
+ for (const row of rows) {
146
+ const kind = evidenceKind(row.evidence_json);
147
+ if (kind)
148
+ return kind;
149
+ }
150
+ return rows.some((row) => row.valence === "correction") ? "correction" : null;
151
+ }
152
+ function renderLessonsScope(input, opts, out) {
153
+ const label = formatScope(input.scope);
154
+ const storeFile = storeFileAttr(input.scope);
155
+ const section = scopeSectionSlug(input.scope);
156
+ const halfLife = opts.recencyHalfLifeDays ?? DEFAULT_RECENCY_HALFLIFE_DAYS;
157
+ const sectionBody = input.existingFileMd
158
+ ? extractMarkdownSection(input.existingFileMd, "Lessons")
159
+ : null;
160
+ const existing = sectionBody ? parseLessonsSection(sectionBody) : [];
161
+ // `current_bytes` measures the on-disk `## Lessons` SECTION body — the
162
+ // §6 cap unit (`lessonsSectionByteLength` in lesson-format.ts), the same
163
+ // unit `enforceCaps` below derives `over_cap` from. Measuring the whole
164
+ // file here while `over_cap` measured the section produced a
165
+ // self-contradictory tag (and disagreed with the §6 unit the eviction
166
+ // engine actually controls).
167
+ const currentBytes = sectionBody
168
+ ? Buffer.byteLength(sectionBody, "utf-8")
169
+ : 0;
170
+ // Eviction ranking: ascending score → rank 1 = evict-first. The plan
171
+ // (post-dedupe) tells the LLM whether the store is already over cap.
172
+ // Pass the same half-life the displayed `ranked` scores use so the plan
173
+ // and the ranking can never derive from two different scorings.
174
+ const plan = enforceCaps(existing, { maxBytes: input.caps.capBytes, maxEntries: input.caps.maxEntries }, opts.nowIso, { scopeLabel: label }, undefined, halfLife);
175
+ const ranked = [...existing].sort((a, b) => scoreLesson(a, opts.nowIso, undefined, halfLife) -
176
+ scoreLesson(b, opts.nowIso, undefined, halfLife));
177
+ out.push(` <scope label="${xmlEscape(label)}" store="${xmlEscape(storeFile)}" ` +
178
+ `section="${xmlEscape(section)}" mode="lessons" ` +
179
+ `cap_bytes="${input.caps.capBytes}" max_entries="${input.caps.maxEntries}" ` +
180
+ `current_bytes="${currentBytes}" current_entries="${existing.length}" ` +
181
+ `over_cap="${plan.evicted.length > 0}">`);
182
+ if (ranked.length > 0) {
183
+ out.push(` <existing_lessons note="ranked by eviction score; drop any lesson marked stale=&quot;true&quot; unless a fresh candidate re-reinforces it; if the section still exceeds the cap after your edits, remove from rank 1 upward then append: ${xmlEscape("- [...N lower-signal lessons omitted — full history in feedback_signals]")}">`);
184
+ ranked.forEach((lesson, idx) => {
185
+ out.push(` <lesson rank="${idx + 1}" score="${round2(scoreLesson(lesson, opts.nowIso, undefined, halfLife))}" ev="${lesson.ev}" kind="${lesson.kind}" last="${lesson.last}" ` +
186
+ `provisional="${lesson.provisional}" ` +
187
+ `stale="${isLessonStale(lesson, opts.nowIso, opts.staleDays)}">` +
188
+ `${inline(lesson.text)}</lesson>`);
189
+ });
190
+ out.push(" </existing_lessons>");
191
+ }
192
+ renderCandidates(input.signals, opts, out, true);
193
+ out.push(" </scope>");
194
+ }
195
+ function renderRawScope(input, opts, out) {
196
+ const label = formatScope(input.scope);
197
+ const storeFile = storeFileAttr(input.scope);
198
+ const section = scopeSectionSlug(input.scope);
199
+ out.push(` <scope label="${xmlEscape(label)}" store="${xmlEscape(storeFile)}" ` +
200
+ `section="${xmlEscape(section)}" mode="raw">`);
201
+ renderCandidates(input.signals, opts, out, false);
202
+ out.push(" </scope>");
203
+ }
204
+ function renderCandidates(signals, opts, out, withVerdict) {
205
+ const groups = groupSignalsBySummary(signals.map((row) => ({ id: row.id, summary: row.summary, row })));
206
+ out.push(" <candidates>");
207
+ for (const group of groups) {
208
+ const ids = group.members.map((member) => member.id).join(",");
209
+ if (withVerdict) {
210
+ const memberRows = group.members.map((member) => member.row);
211
+ const verdict = evaluatePromotion(memberRows.map((row) => ({
212
+ source: row.source,
213
+ valence: row.valence,
214
+ })), opts.promotionThreshold);
215
+ const src = dominantSource(memberRows);
216
+ const kind = candidateKind(memberRows);
217
+ out.push(` <candidate signals="${group.members.length}" ` +
218
+ `weighted_ev="${round2(verdict.weightedEv)}" ` +
219
+ `decision="${verdict.promotable ? "promote" : "hold-provisional"}" ` +
220
+ `conf="${verdict.conf}" src="${src}"` +
221
+ (kind ? ` kind="${kind}"` : "") +
222
+ ` reason="${verdict.reason}" ids="${ids}">` +
223
+ `${inline(group.summary)}</candidate>`);
224
+ }
225
+ else {
226
+ out.push(` <candidate signals="${group.members.length}" ids="${ids}">` +
227
+ `${inline(group.summary)}</candidate>`);
228
+ }
229
+ }
230
+ out.push(" </candidates>");
231
+ }
232
+ /**
233
+ * Compose the `<feedback_worksheet>` block. Returns `null` when there are no
234
+ * signals at all (the caller then stamps nothing — no empty block in the prompt).
235
+ */
236
+ export function buildFeedbackWorksheet(scopes, opts) {
237
+ const signalIds = scopes.flatMap((scope) => scope.signals.map((signal) => signal.id));
238
+ if (signalIds.length === 0)
239
+ return null;
240
+ const out = [];
241
+ out.push(`<feedback_worksheet generated_at="${xmlEscape(opts.nowIso)}" ` +
242
+ `promotion_threshold="${opts.promotionThreshold}" scopes="${scopes.length}">`);
243
+ for (const scope of scopes) {
244
+ if (scope.caps) {
245
+ renderLessonsScope({ ...scope, caps: scope.caps }, opts, out);
246
+ }
247
+ else {
248
+ renderRawScope(scope, opts, out);
249
+ }
250
+ }
251
+ out.push(` <consume ids="${signalIds.join(",")}" />`);
252
+ out.push("</feedback_worksheet>");
253
+ return { block: out.join("\n"), signalIds, scopeCount: scopes.length };
254
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Feedback Learning Loop — eviction scorer + cap enforcer (FEEDBACK_LEARNING_LOOP_DESIGN.md §6).
3
+ *
4
+ * A **new** pure-logic module (not `trimBulletEntries`, which is recency
5
+ * top-N with no notion of `ev`/`kind`; not `clearEntriesBefore`, which keys on
6
+ * the *leading* `[date]` not the trailer `last=`). It scores lessons and, when
7
+ * a file is over its per-scope byte/entry cap, evicts the lowest-scored first
8
+ * — provisional + stale go first — emitting an `[...N omitted]` marker.
9
+ *
10
+ * score = w_ev·log(ev+1) + w_recency·decay(last) + w_kind·importance(kind)
11
+ *
12
+ * where importance is `constraint > correction > do-more/do-less > preference`.
13
+ * Provisional lessons carry a fixed penalty so they sort below active peers of
14
+ * equal evidence. Near-duplicates are merged (their `ev` summed) *before*
15
+ * eviction is considered, so a merged lesson is harder to evict, never easier.
16
+ */
17
+ import { type Lesson, type LessonKind } from "./lesson-format.js";
18
+ export interface EvictionWeights {
19
+ ev: number;
20
+ recency: number;
21
+ kind: number;
22
+ /** Subtracted from a provisional lesson's score so it evicts first. */
23
+ provisionalPenalty: number;
24
+ }
25
+ export declare const DEFAULT_EVICTION_WEIGHTS: EvictionWeights;
26
+ /** Half-life (days) of the recency decay term. */
27
+ export declare const DEFAULT_RECENCY_HALFLIFE_DAYS = 45;
28
+ /** `constraint` > `correction` > `do-more`/`do-less` > `preference`. */
29
+ export declare function kindImportance(kind: LessonKind): number;
30
+ /**
31
+ * Exponential recency decay in `[0, 1]`: `1` for a lesson reinforced today,
32
+ * `0.5` at one half-life, approaching `0` for ancient lessons. A future or
33
+ * unparseable `last` clamps to `1` (treated as fresh — never penalised for a
34
+ * clock/format quirk).
35
+ */
36
+ export declare function recencyDecay(last: string, nowIso: string, halfLifeDays?: number): number;
37
+ /** Composite eviction score — higher means keep, lower means evict first. */
38
+ export declare function scoreLesson(lesson: Lesson, nowIso: string, weights?: EvictionWeights, halfLifeDays?: number): number;
39
+ /**
40
+ * §4 step 7 staleness test — a lesson is prunable for staleness when its `last`
41
+ * reinforcement predates `now − staleDays` and it is not a durable
42
+ * `constraint`. Shared single source of truth for both worksheet builders
43
+ * (the nightly `consolidation-prep` and the monthly `regeneralization-prep`)
44
+ * so the `stale="…"` flag they stamp can never drift apart.
45
+ *
46
+ * Semantics (kept byte-stable across the two prior local copies):
47
+ * - no horizon configured (`staleDays === undefined`) ⇒ never stale;
48
+ * - `kind=constraint` ⇒ never stale (durable);
49
+ * - an unparseable `last` (or `nowIso`) yields a `NaN` comparison, which is
50
+ * `false` — i.e. never prune on a clock/format quirk. Reuses {@link dateToMs}
51
+ * for the same `YYYY-MM-DD → epoch ms` parse the recency decay uses.
52
+ */
53
+ export declare function isLessonStale(lesson: Lesson, nowIso: string, staleDays: number | undefined): boolean;
54
+ export interface CapConfig {
55
+ maxBytes: number;
56
+ maxEntries: number;
57
+ }
58
+ export interface EvictionPlan {
59
+ /** Lessons that survive, in eviction-score order (highest first). */
60
+ keep: Lesson[];
61
+ /** Lessons removed to satisfy the cap, lowest-scored first. */
62
+ evicted: Lesson[];
63
+ /** `[...N … omitted]` marker when anything was evicted, else `null`. */
64
+ omittedMarker: string | null;
65
+ /** Serialized byte length of the kept section incl. header + marker. */
66
+ bytes: number;
67
+ }
68
+ export declare function omittedMarker(count: number): string;
69
+ /**
70
+ * Dedupe, score, sort (highest first), then evict the lowest-scored lessons
71
+ * until the section fits both `maxEntries` and `maxBytes`. The byte cap is
72
+ * checked against the *serialized* section (header + bullets + marker) so the
73
+ * measured size matches what lands on disk.
74
+ *
75
+ * Always makes progress when over the byte cap with ≥1 lesson — even a single
76
+ * lesson longer than the cap is reduced to an empty kept set with the marker —
77
+ * so the loop terminates.
78
+ */
79
+ export declare function enforceCaps(lessons: ReadonlyArray<Lesson>, cap: CapConfig, nowIso: string, opts: {
80
+ scopeLabel: string;
81
+ }, weights?: EvictionWeights, halfLifeDays?: number): EvictionPlan;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Feedback Learning Loop — eviction scorer + cap enforcer (FEEDBACK_LEARNING_LOOP_DESIGN.md §6).
3
+ *
4
+ * A **new** pure-logic module (not `trimBulletEntries`, which is recency
5
+ * top-N with no notion of `ev`/`kind`; not `clearEntriesBefore`, which keys on
6
+ * the *leading* `[date]` not the trailer `last=`). It scores lessons and, when
7
+ * a file is over its per-scope byte/entry cap, evicts the lowest-scored first
8
+ * — provisional + stale go first — emitting an `[...N omitted]` marker.
9
+ *
10
+ * score = w_ev·log(ev+1) + w_recency·decay(last) + w_kind·importance(kind)
11
+ *
12
+ * where importance is `constraint > correction > do-more/do-less > preference`.
13
+ * Provisional lessons carry a fixed penalty so they sort below active peers of
14
+ * equal evidence. Near-duplicates are merged (their `ev` summed) *before*
15
+ * eviction is considered, so a merged lesson is harder to evict, never easier.
16
+ */
17
+ import { formatLessonsSection, } from "./lesson-format.js";
18
+ import { dedupeLessons } from "./lesson-merge.js";
19
+ export const DEFAULT_EVICTION_WEIGHTS = {
20
+ ev: 1.0,
21
+ recency: 1.0,
22
+ kind: 0.75,
23
+ provisionalPenalty: 1.0,
24
+ };
25
+ /** Half-life (days) of the recency decay term. */
26
+ export const DEFAULT_RECENCY_HALFLIFE_DAYS = 45;
27
+ const KIND_IMPORTANCE = {
28
+ constraint: 4,
29
+ correction: 3,
30
+ "do-more": 2,
31
+ "do-less": 2,
32
+ preference: 1,
33
+ };
34
+ /** `constraint` > `correction` > `do-more`/`do-less` > `preference`. */
35
+ export function kindImportance(kind) {
36
+ return KIND_IMPORTANCE[kind];
37
+ }
38
+ function dateToMs(date) {
39
+ const ms = Date.parse(`${date.slice(0, 10)}T00:00:00Z`);
40
+ return Number.isFinite(ms) ? ms : NaN;
41
+ }
42
+ /**
43
+ * Exponential recency decay in `[0, 1]`: `1` for a lesson reinforced today,
44
+ * `0.5` at one half-life, approaching `0` for ancient lessons. A future or
45
+ * unparseable `last` clamps to `1` (treated as fresh — never penalised for a
46
+ * clock/format quirk).
47
+ */
48
+ export function recencyDecay(last, nowIso, halfLifeDays = DEFAULT_RECENCY_HALFLIFE_DAYS) {
49
+ const lastMs = dateToMs(last);
50
+ const nowMs = Date.parse(nowIso);
51
+ if (!Number.isFinite(lastMs) || !Number.isFinite(nowMs))
52
+ return 1;
53
+ const ageDays = (nowMs - lastMs) / 86_400_000;
54
+ if (ageDays <= 0)
55
+ return 1;
56
+ return Math.pow(0.5, ageDays / halfLifeDays);
57
+ }
58
+ /** Composite eviction score — higher means keep, lower means evict first. */
59
+ export function scoreLesson(lesson, nowIso, weights = DEFAULT_EVICTION_WEIGHTS, halfLifeDays = DEFAULT_RECENCY_HALFLIFE_DAYS) {
60
+ const evTerm = weights.ev * Math.log(Math.max(lesson.ev, 0) + 1);
61
+ const recencyTerm = weights.recency * recencyDecay(lesson.last, nowIso, halfLifeDays);
62
+ const kindTerm = weights.kind * kindImportance(lesson.kind);
63
+ const penalty = lesson.provisional ? weights.provisionalPenalty : 0;
64
+ return evTerm + recencyTerm + kindTerm - penalty;
65
+ }
66
+ /**
67
+ * §4 step 7 staleness test — a lesson is prunable for staleness when its `last`
68
+ * reinforcement predates `now − staleDays` and it is not a durable
69
+ * `constraint`. Shared single source of truth for both worksheet builders
70
+ * (the nightly `consolidation-prep` and the monthly `regeneralization-prep`)
71
+ * so the `stale="…"` flag they stamp can never drift apart.
72
+ *
73
+ * Semantics (kept byte-stable across the two prior local copies):
74
+ * - no horizon configured (`staleDays === undefined`) ⇒ never stale;
75
+ * - `kind=constraint` ⇒ never stale (durable);
76
+ * - an unparseable `last` (or `nowIso`) yields a `NaN` comparison, which is
77
+ * `false` — i.e. never prune on a clock/format quirk. Reuses {@link dateToMs}
78
+ * for the same `YYYY-MM-DD → epoch ms` parse the recency decay uses.
79
+ */
80
+ export function isLessonStale(lesson, nowIso, staleDays) {
81
+ if (staleDays === undefined || lesson.kind === "constraint")
82
+ return false;
83
+ const lastMs = dateToMs(lesson.last);
84
+ const nowMs = Date.parse(nowIso);
85
+ return (nowMs - lastMs) / 86_400_000 > staleDays;
86
+ }
87
+ export function omittedMarker(count) {
88
+ return `- [...${count} lower-signal lessons omitted — full history in feedback_signals]`;
89
+ }
90
+ /**
91
+ * Dedupe, score, sort (highest first), then evict the lowest-scored lessons
92
+ * until the section fits both `maxEntries` and `maxBytes`. The byte cap is
93
+ * checked against the *serialized* section (header + bullets + marker) so the
94
+ * measured size matches what lands on disk.
95
+ *
96
+ * Always makes progress when over the byte cap with ≥1 lesson — even a single
97
+ * lesson longer than the cap is reduced to an empty kept set with the marker —
98
+ * so the loop terminates.
99
+ */
100
+ export function enforceCaps(lessons, cap, nowIso, opts, weights = DEFAULT_EVICTION_WEIGHTS, halfLifeDays = DEFAULT_RECENCY_HALFLIFE_DAYS) {
101
+ const deduped = dedupeLessons(lessons);
102
+ const sorted = [...deduped].sort((a, b) => scoreLesson(b, nowIso, weights, halfLifeDays) -
103
+ scoreLesson(a, nowIso, weights, halfLifeDays));
104
+ const evicted = [];
105
+ let keep = sorted;
106
+ // Entry cap first — cheap, and shrinks the byte-cap work. Reverse the
107
+ // overflow slice (it comes off the descending-sorted array) so `evicted`
108
+ // honours its documented lowest-scored-first order; the byte-cap loop
109
+ // below already pushes lowest-first, so the combined array stays
110
+ // ascending by score.
111
+ if (keep.length > cap.maxEntries) {
112
+ evicted.push(...keep.slice(cap.maxEntries).reverse());
113
+ keep = keep.slice(0, cap.maxEntries);
114
+ }
115
+ const sectionOpts = {
116
+ scopeLabel: opts.scopeLabel,
117
+ capBytes: cap.maxBytes,
118
+ maxEntries: cap.maxEntries,
119
+ };
120
+ const measure = (lessonsToMeasure) => Buffer.byteLength(formatLessonsSection(lessonsToMeasure, {
121
+ ...sectionOpts,
122
+ omittedMarker: evicted.length > 0 ? omittedMarker(evicted.length) : null,
123
+ }), "utf-8");
124
+ // Byte cap — drop lowest-scored (tail of the sorted array) until it fits.
125
+ while (keep.length > 0 && measure(keep) > cap.maxBytes) {
126
+ evicted.push(keep[keep.length - 1]);
127
+ keep = keep.slice(0, -1);
128
+ }
129
+ const marker = evicted.length > 0 ? omittedMarker(evicted.length) : null;
130
+ return {
131
+ keep,
132
+ evicted,
133
+ omittedMarker: marker,
134
+ bytes: Buffer.byteLength(formatLessonsSection(keep, { ...sectionOpts, omittedMarker: marker }), "utf-8"),
135
+ };
136
+ }