@ilaomianyang/dsh-memory-graph 0.10.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 (50) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +466 -0
  4. package/README.md +459 -0
  5. package/client.js +1953 -0
  6. package/cordis.patch.yml +13 -0
  7. package/docs/auto-settings.png +0 -0
  8. package/docs/distill-button.png +0 -0
  9. package/docs/graph-dark.png +0 -0
  10. package/docs/graph-light.png +0 -0
  11. package/docs/session-tab-cards.png +0 -0
  12. package/docs/session-tab-open.png +0 -0
  13. package/docs/session-tab.png +0 -0
  14. package/docs/vault-panel.png +0 -0
  15. package/eval/REPORT.md +437 -0
  16. package/eval/questions.json +277 -0
  17. package/eval/run.mjs +460 -0
  18. package/eval/seed.mjs +489 -0
  19. package/icon.svg +26 -0
  20. package/index.js +243 -0
  21. package/locale/en.json +6 -0
  22. package/locale/zh.json +6 -0
  23. package/package.json +77 -0
  24. package/src/auto.js +194 -0
  25. package/src/capture.js +237 -0
  26. package/src/config.js +280 -0
  27. package/src/distillapi.js +30 -0
  28. package/src/distillrun.js +106 -0
  29. package/src/extract.js +660 -0
  30. package/src/graph.js +1582 -0
  31. package/src/graphdata.js +475 -0
  32. package/src/http.js +32 -0
  33. package/src/pageapi.js +40 -0
  34. package/src/privacy.js +78 -0
  35. package/src/schema.js +323 -0
  36. package/src/search.js +317 -0
  37. package/src/tools.js +534 -0
  38. package/src/vault.js +888 -0
  39. package/test/auto.mjs +295 -0
  40. package/test/capture.mjs +264 -0
  41. package/test/client.mjs +1035 -0
  42. package/test/distillapi.mjs +212 -0
  43. package/test/extract.mjs +660 -0
  44. package/test/graphdata.mjs +295 -0
  45. package/test/live-supersede.mjs +78 -0
  46. package/test/live.mjs +156 -0
  47. package/test/page.mjs +116 -0
  48. package/test/schema.mjs +256 -0
  49. package/test/smoke.mjs +428 -0
  50. package/test/tools.mjs +238 -0
package/index.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * dsh-memory-graph — associative long-term memory for DeepSeek Harness.
3
+ *
4
+ * Design in one paragraph: the session log already remembers everything and
5
+ * gets compacted away. This plugin keeps a second, durable layer outside the
6
+ * context window: raw episodes (never distilled, never lost), typed memory
7
+ * cards with temporal validity and a supersede chain, and one page per entity
8
+ * so separate projects connect through the things they share. Retrieval is
9
+ * explicit — the agent asks — so nothing is injected into a turn that does not
10
+ * need it. Storage is plain files under one directory: readable, portable,
11
+ * greppable, and owned by the user.
12
+ *
13
+ * Configuration has two doors. The profile's `cordis.patch.yml` is the durable
14
+ * record (hand-written, or written by 设置 → 记忆图谱); the fields marked
15
+ * `.volatile()` in `src/schema.js` can also be edited there while the plugin
16
+ * runs. A volatile-only write does not remount the plugin: the Loader updates the
17
+ * live references in place and emits `loader/volatile-update`, and `cfg` below is
18
+ * refreshed in place so every reader of `cfg.a.b` — capture, tools, extraction,
19
+ * search — sees the new value on its next call. Structural knobs (`enabled`,
20
+ * `vaultDir`) stay ordinary on purpose: they are consumed once, so changing one
21
+ * re-activates the plugin, which is the semantics it needs.
22
+ */
23
+ import { resolveConfig } from './src/config.js'
24
+ import { createRedactor } from './src/privacy.js'
25
+ import { createVault } from './src/vault.js'
26
+ import { registerTools } from './src/tools.js'
27
+ import { registerCapture } from './src/capture.js'
28
+ import { createGraphHandler, GRAPH_PATH } from './src/graphdata.js'
29
+ import { createDistillHandler, DISTILL_PATH } from './src/distillapi.js'
30
+ import { createPageHandler, PAGE_PATH } from './src/pageapi.js'
31
+ import { createDistillRunner } from './src/distillrun.js'
32
+ import { createAutoDistiller, registerAutoDistill } from './src/auto.js'
33
+ import { extractorServices } from './src/extract.js'
34
+
35
+ export const name = 'memory-graph'
36
+ export const inject = ['tools']
37
+
38
+ /**
39
+ * Settings-form schema.
40
+ *
41
+ * The harness draws a plugin's page in 设置 from the schema it exports, and it
42
+ * builds that schema with its own schemastery. So this one import is the only
43
+ * dependency the plugin has; it is loaded opportunistically so a checkout that
44
+ * never ran `npm install` still works — it just has no Settings page and runs on
45
+ * the defaults in `src/config.js`.
46
+ */
47
+ let Config
48
+ try {
49
+ ;({ Config } = await import('./src/schema.js'))
50
+ } catch {
51
+ Config = undefined
52
+ }
53
+ export { Config }
54
+
55
+ /**
56
+ * A redactor whose rules can be replaced while it is in use.
57
+ *
58
+ * Handing the plain `createRedactor` result to the vault, the tools and the
59
+ * extractor would freeze the privacy rules at activation time, because they hold
60
+ * that object. This wrapper forwards every call to the rules that are current
61
+ * now, so editing `privacy.*` in 设置 applies to the next string written instead
62
+ * of the next restart.
63
+ */
64
+ function createLiveRedactor(cfg) {
65
+ let current = createRedactor(cfg.privacy)
66
+ return {
67
+ redact: (text, where) => current.redact(text, where),
68
+ get ruleCount() {
69
+ return current.ruleCount
70
+ },
71
+ rebuild() {
72
+ current = createRedactor(cfg.privacy)
73
+ },
74
+ }
75
+ }
76
+
77
+ export function apply(ctx, config) {
78
+ const cfg = resolveConfig(config)
79
+ const redactor = createLiveRedactor(cfg)
80
+
81
+ // Re-resolving in place, rather than re-running apply, is what makes a Settings
82
+ // write land: every consumer holds this one `cfg` object and reads `cfg.a.b` at
83
+ // call time, so the new values are live with no other file touched. A change to
84
+ // an ordinary field still remounts the plugin, which is when this listener is
85
+ // rebuilt against the new activation.
86
+ ctx.on('loader/volatile-update', () => {
87
+ try {
88
+ Object.assign(cfg, resolveConfig(config))
89
+ redactor.rebuild()
90
+ } catch {
91
+ // A half-written config must not break the running plugin: a stale
92
+ // snapshot is better than a throw on every edit.
93
+ }
94
+ })
95
+
96
+ if (!cfg.enabled) return
97
+
98
+ const vault = createVault(cfg, redactor)
99
+
100
+ const log = (level, message) => {
101
+ try {
102
+ ctx.logger?.[level]?.(`[memory-graph] ${message}`)
103
+ } catch {
104
+ // Logging must never break activation.
105
+ }
106
+ }
107
+
108
+ ctx.effect(() => {
109
+ vault
110
+ .ensure()
111
+ .then(() => vault.regenerateReadme())
112
+ .then(() => log('info', `vault ready at ${cfg.vaultDir}`))
113
+ .catch((error) => log('warn', `vault unavailable at ${cfg.vaultDir}: ${error?.message ?? error}`))
114
+ return () => {}
115
+ })
116
+
117
+ // One distillation at a time, for the whole plugin. Three callers can ask for
118
+ // one — the model's tool, the panel's button, and the automatic distiller — and
119
+ // a distillation spends real model budget while advancing one cursor in the
120
+ // vault, so they all go through this runner and the second caller is refused
121
+ // rather than queued.
122
+ const runner = createDistillRunner({
123
+ getVault: () => vault,
124
+ getConfig: () => cfg,
125
+ getRedactor: () => redactor,
126
+ getServices: () => extractorServices(ctx),
127
+ refreshViews: async () => {
128
+ for (const render of [vault.regenerateReadme, vault.regenerateGraph]) {
129
+ if (typeof render === 'function') await render.call(vault)
130
+ }
131
+ },
132
+ log,
133
+ })
134
+
135
+ ctx.effect(() => {
136
+ const disposers = registerTools(ctx, { cfg, vault, redactor, runner })
137
+ log('info', `${disposers.length} memory tools registered`)
138
+ return () => {
139
+ for (const dispose of disposers) {
140
+ try {
141
+ dispose()
142
+ } catch {
143
+ // Disposal of an already-gone registration is not an error.
144
+ }
145
+ }
146
+ }
147
+ })
148
+
149
+ // The automatic distiller: a tick that decides, never a timer that fires a run.
150
+ // Both triggers (a threshold of waiting episodes, and "every N minutes") default
151
+ // to off and are live Settings fields, so the ticker runs whether or not they
152
+ // are on and simply reads the config each time.
153
+ ctx.effect(() => {
154
+ const distiller = createAutoDistiller({ cfg, vault, run: (options) => runner.run(options), log })
155
+ const stop = registerAutoDistill({ ctx, distiller })
156
+ log('info', `auto distill ${cfg.auto.enabled ? 'on' : 'off'} (threshold ${cfg.auto.minEpisodes || '—'}, every ${cfg.auto.everyMinutes || '—'} min)`)
157
+ return () => {
158
+ try {
159
+ stop()
160
+ } catch {
161
+ // ignore
162
+ }
163
+ }
164
+ })
165
+
166
+ ctx.effect(() => {
167
+ const disposers = registerCapture(ctx, { cfg, vault, redactor })
168
+ log('info', `episode capture registered (${cfg.capture.enabled ? 'on' : 'off'})`)
169
+ return () => {
170
+ for (const dispose of disposers) {
171
+ try {
172
+ dispose()
173
+ } catch {
174
+ // ignore
175
+ }
176
+ }
177
+ }
178
+ })
179
+
180
+ // The Session memory view's data door: one exact Fetch route on the
181
+ // authenticated `/api` channel, so the browser sends the cookie it already
182
+ // holds and Connection rejects anything that is not the owner Peer. Two
183
+ // deliberate choices here.
184
+ //
185
+ // `connection` is optional, not in `inject`: a headless or CLI profile has no
186
+ // web server and no browser, and the plugin must keep working there — only the
187
+ // panel is missing. `ctx.inject` keeps the subscription until the service
188
+ // appears, so a profile that adds the web GUI later still gets the route.
189
+ //
190
+ // The disposer is held by this plugin's effect, not by the Connection
191
+ // service's: uninstalling or disabling the plugin must remove the door.
192
+ ctx.effect(() => {
193
+ const disposeInject = ctx.inject(['connection'], (inner) => {
194
+ const registry = inner.get('connection')?.fetch
195
+ if (typeof registry?.register !== 'function') {
196
+ // A build without exact Fetch routes: the plugin keeps working, the tab
197
+ // explains itself instead of hanging on a request that can never land.
198
+ log('warn', 'connection.fetch is unavailable; the session memory view has no data route')
199
+ return () => {}
200
+ }
201
+ const disposeRoute = registry.register({
202
+ path: GRAPH_PATH,
203
+ methods: ['GET'],
204
+ requestBody: 'buffered',
205
+ fetch: createGraphHandler({ getVault: () => vault, getConfig: () => cfg }),
206
+ })
207
+ // The button's door. Same distillation the `memory_distill` tool runs,
208
+ // reached from the panel; POST only, so a page load or a prefetch can never
209
+ // spend model budget by accident.
210
+ const disposeDistill = registry.register({
211
+ path: DISTILL_PATH,
212
+ methods: ['POST'],
213
+ requestBody: 'buffered',
214
+ fetch: createDistillHandler({ getRunner: () => runner }),
215
+ })
216
+ // The whole-vault graph, as the page the sidebar panel embeds. GET only and
217
+ // read-only: it renders from cards already on disk and writes nothing.
218
+ const disposePage = registry.register({
219
+ path: PAGE_PATH,
220
+ methods: ['GET'],
221
+ requestBody: 'buffered',
222
+ fetch: createPageHandler({ getVault: () => vault }),
223
+ })
224
+ log('info', `session memory view served at ${GRAPH_PATH}, distill at ${DISTILL_PATH}, vault graph page at ${PAGE_PATH}`)
225
+ return () => {
226
+ for (const dispose of [disposeRoute, disposeDistill, disposePage]) {
227
+ try {
228
+ dispose?.()
229
+ } catch {
230
+ // A route already gone is not an error.
231
+ }
232
+ }
233
+ }
234
+ })
235
+ return () => {
236
+ try {
237
+ disposeInject?.()
238
+ } catch {
239
+ // ignore
240
+ }
241
+ }
242
+ })
243
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Memory Graph",
4
+ "description": "Long-term associative memory in a plain-text vault: episodes, typed cards, entity pages and a timeline. Readable on GitHub, diffable, no database."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "记忆图谱",
4
+ "description": "以纯文本 vault 保存的长期联想记忆:事件流、带类型的记忆卡片、实体页与时间轴。可直接在 GitHub 阅读、可 diff、不依赖数据库。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@ilaomianyang/dsh-memory-graph",
3
+ "version": "0.10.0",
4
+ "type": "module",
5
+ "author": "ISKING <admin@isking.cc>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ilaomianyang/dsh-memory-graph.git"
9
+ },
10
+ "homepage": "https://github.com/ilaomianyang/dsh-memory-graph#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/ilaomianyang/dsh-memory-graph/issues"
13
+ },
14
+ "description": "Long-term associative memory for DeepSeek Harness: a local plain-text vault of raw episodes, typed memory cards and entity pages, configured from 设置 → 记忆图谱, and a Memory tab inside a conversation that draws what that session remembered. No build step; the only dependency is the harness's own schema library, used for the Settings page.",
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "cordis",
19
+ "memory",
20
+ "long-term-memory",
21
+ "knowledge-graph",
22
+ "local-first"
23
+ ],
24
+ "exports": {
25
+ ".": "./index.js",
26
+ "./client": "./client.js",
27
+ "./cordis.patch.yml": "./cordis.patch.yml",
28
+ "./package.json": "./package.json",
29
+ "./locale/*.json": "./locale/*.json",
30
+ "./icon": "./icon.svg"
31
+ },
32
+ "files": [
33
+ "index.js",
34
+ "client.js",
35
+ "src/*.js",
36
+ "test/*.mjs",
37
+ "eval/*.mjs",
38
+ "eval/questions.json",
39
+ "eval/REPORT.md",
40
+ "cordis.patch.yml",
41
+ "docs/*.png",
42
+ "locale/*.json",
43
+ "icon.svg",
44
+ "README.md",
45
+ "README.en.md",
46
+ "CHANGELOG.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "test": "node test/smoke.mjs && node test/schema.mjs && node test/client.mjs && node test/capture.mjs && node test/tools.mjs && node test/extract.mjs && node test/graphdata.mjs && node test/page.mjs && node test/distillapi.mjs && node test/auto.mjs && node eval/run.mjs",
51
+ "eval": "node eval/run.mjs",
52
+ "live": "node test/live.mjs",
53
+ "live:supersede": "node test/live-supersede.mjs",
54
+ "check": "node --check index.js",
55
+ "prepublishOnly": "npm test"
56
+ },
57
+ "dsh": {
58
+ "bundle": {
59
+ "patch": "./cordis.patch.yml"
60
+ },
61
+ "client": {
62
+ "platform": "web",
63
+ "inject": []
64
+ }
65
+ },
66
+ "engines": {
67
+ "node": ">=20"
68
+ },
69
+ "publishConfig": {
70
+ "access": "public",
71
+ "registry": "https://registry.npmjs.org/"
72
+ },
73
+ "license": "MIT",
74
+ "dependencies": {
75
+ "@deepseek-ai/schemastery": "~3.18.5"
76
+ }
77
+ }
package/src/auto.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The automatic distiller: two independently configurable triggers over one tick.
3
+ *
4
+ * The rule that matters most is that neither trigger is a timer that fires a run.
5
+ * A tick only ever *decides*, and the decision is a pure function of the config,
6
+ * the clock, the last attempt and how many episodes are waiting — so the whole
7
+ * behaviour can be asserted in tests without waiting a minute or spending a token.
8
+ *
9
+ * Why the attempt clock is separate from the vault's `lastRun`: a run that
10
+ * distilled nothing (an empty vault, a model that returned no cards) must still
11
+ * arm the gap, or a quiet vault would be re-asked on every tick. `lastAttemptAt`
12
+ * is therefore written before the call, not after it.
13
+ */
14
+ import { parseQuietHours } from './config.js'
15
+
16
+ const MINUTE = 60 * 1000
17
+
18
+ /** Is `now` inside the quiet window? A window may cross midnight. */
19
+ export function inQuietHours(window, now) {
20
+ const bounds = parseQuietHours(window)
21
+ if (bounds === null) return false
22
+ const date = new Date(now)
23
+ const minutes = date.getHours() * 60 + date.getMinutes()
24
+ if (bounds.from === bounds.to) return false
25
+ // 23:00-08:00 wraps: quiet when at-or-after the start, or before the end.
26
+ return bounds.from < bounds.to
27
+ ? minutes >= bounds.from && minutes < bounds.to
28
+ : minutes >= bounds.from || minutes < bounds.to
29
+ }
30
+
31
+ /**
32
+ * Should this tick run a distillation? Pure, so the rules are readable in one
33
+ * place and testable without a clock.
34
+ *
35
+ * @param {object} input
36
+ * @param {object} input.auto the `auto` config group
37
+ * @param {number} input.now epoch ms
38
+ * @param {number|null} input.lastAttemptAt epoch ms of the previous attempt
39
+ * @param {number} input.pending undistilled episodes waiting
40
+ * @returns {{due: boolean, rule: string|null, reason: string}}
41
+ */
42
+ export function autoDecision({ auto, now, lastAttemptAt = null, startedAt = null, pending = 0 }) {
43
+ if (auto?.enabled !== true) return { due: false, rule: null, reason: 'auto is off' }
44
+ if (pending <= 0) return { due: false, rule: null, reason: 'nothing to distill' }
45
+ if (inQuietHours(auto.quietHours, now)) return { due: false, rule: null, reason: 'quiet hours' }
46
+
47
+ // Two different clocks, on purpose:
48
+ // * the gap measures time since the last *run* (nothing else);
49
+ // * the schedule measures time since the last run, or since the clock was
50
+ // started when there has not been one yet.
51
+ // `null` for either means "not started", and a schedule with no clock is never
52
+ // overdue — turning a daily schedule on must not spend on the spot. The
53
+ // threshold rule ignores both clocks: its trigger is the pile, not the time.
54
+ const sinceRun = lastAttemptAt === null ? null : now - lastAttemptAt
55
+ const clock = lastAttemptAt ?? startedAt
56
+ const sinceClock = clock === null ? null : now - clock
57
+
58
+ const gap = (auto.minGapMinutes ?? 0) * MINUTE
59
+ if (gap > 0 && sinceRun !== null && sinceRun < gap) {
60
+ return { due: false, rule: null, reason: 'the gap between runs has not elapsed' }
61
+ }
62
+
63
+ // Schedule rule: due once `everyMinutes` have passed on its own clock.
64
+ if ((auto.everyMinutes ?? 0) > 0 && sinceClock !== null && sinceClock >= auto.everyMinutes * MINUTE) {
65
+ return { due: true, rule: 'schedule', reason: `every ${auto.everyMinutes} minute(s)` }
66
+ }
67
+
68
+ // Threshold rule: enough quiet episodes have piled up.
69
+ if ((auto.minEpisodes ?? 0) > 0 && pending >= auto.minEpisodes) {
70
+ return { due: true, rule: 'threshold', reason: `${pending} episode(s) waiting, threshold ${auto.minEpisodes}` }
71
+ }
72
+
73
+ return { due: false, rule: null, reason: 'nothing is due yet' }
74
+ }
75
+
76
+ /**
77
+ * The ticker.
78
+ *
79
+ * `run` is the shared runner from `src/distillrun.js`, so an automatic run and a
80
+ * pressed button can never overlap. The state file lives in the vault so an app
81
+ * restart does not reset the gap and fire a run on every launch.
82
+ */
83
+ export function createAutoDistiller({ cfg, vault, run, log = () => {}, now = () => Date.now() }) {
84
+ let loaded = false
85
+ let lastAttemptAt = null
86
+ let startedAt = null
87
+ let lastReport = null
88
+
89
+ async function loadState() {
90
+ if (loaded) return
91
+ loaded = true
92
+ try {
93
+ const state = await vault.readAutoState?.()
94
+ const attempt = Number(state?.lastAttemptAt)
95
+ const start = Number(state?.startedAt)
96
+ lastAttemptAt = Number.isFinite(attempt) && attempt > 0 ? attempt : null
97
+ startedAt = Number.isFinite(start) && start > 0 ? start : null
98
+ } catch (error) {
99
+ log('warn', `auto: could not read .auto-state.json: ${error?.message ?? error}`)
100
+ }
101
+ }
102
+
103
+ /** One decision, and at most one run. Returns what it decided, for the tests. */
104
+ async function tick() {
105
+ const auto = cfg.auto ?? {}
106
+ await loadState()
107
+ if (auto.enabled !== true) return { due: false, rule: null, reason: 'auto is off', ran: false }
108
+
109
+ let pending = 0
110
+ try {
111
+ const batch = await vault.readUndistilled(Math.max(1, auto.minEpisodes ?? 0, cfg.extractor?.maxEpisodesPerRun ?? 40))
112
+ pending = Array.isArray(batch) ? batch.length : 0
113
+ } catch (error) {
114
+ log('warn', `auto: could not count undistilled episodes: ${error?.message ?? error}`)
115
+ return { due: false, rule: null, reason: 'the vault could not be read', ran: false }
116
+ }
117
+
118
+ const at = now()
119
+ // Start the schedule's clock when it has none yet (just switched on, or a
120
+ // fresh install) instead of treating it as overdue: switching a daily
121
+ // schedule on must not spend before the first interval has even passed. This
122
+ // is a separate field from `lastAttemptAt` so it does not arm the gap and
123
+ // delay the threshold rule, which has nothing to do with the clock.
124
+ if ((auto.everyMinutes ?? 0) > 0 && lastAttemptAt === null && startedAt === null) {
125
+ startedAt = at
126
+ try {
127
+ await vault.writeAutoState?.({ startedAt: at })
128
+ } catch (error) {
129
+ log('warn', `auto: could not start the schedule clock: ${error?.message ?? error}`)
130
+ }
131
+ }
132
+
133
+ const decision = autoDecision({ auto, now: at, lastAttemptAt, startedAt, pending })
134
+ if (!decision.due) return { ...decision, pending, ran: false }
135
+
136
+ // Arm the gap before the call: a slow or failing run must not be retried on
137
+ // the next tick.
138
+ lastAttemptAt = at
139
+ try {
140
+ await vault.writeAutoState?.({ lastAttemptAt: at })
141
+ } catch (error) {
142
+ log('warn', `auto: could not record the attempt: ${error?.message ?? error}`)
143
+ }
144
+
145
+ const report = await run({ project: auto.project ? String(auto.project) : undefined })
146
+ lastReport = report
147
+ if (report?.busy === true) {
148
+ log('info', 'auto: skipped, a distillation is already running')
149
+ } else if (report?.ok === true) {
150
+ log(
151
+ 'info',
152
+ `auto (${decision.rule}): read ${report.episodes} episode(s) → wrote ${report.written} card(s)` +
153
+ (report.model ? ` · ${report.model}` : ''),
154
+ )
155
+ } else {
156
+ log('warn', `auto (${decision.rule}): ${report?.message ?? report?.reason ?? 'did not run'}`)
157
+ }
158
+ return { ...decision, pending, ran: true, report }
159
+ }
160
+
161
+ /** What the ticker knows, for a diagnostics line or a test. */
162
+ function state() {
163
+ return { lastAttemptAt, lastReport }
164
+ }
165
+
166
+ return { tick, state }
167
+ }
168
+
169
+ /**
170
+ * Wire the ticker to the plugin's lifetime.
171
+ *
172
+ * The interval runs even when auto is off — it reads one boolean and returns —
173
+ * because `auto.enabled` is a live Settings field, and a ticker that only started
174
+ * when the plugin mounted would never see it switched on.
175
+ */
176
+ export function registerAutoDistill({ ctx, distiller, intervalMs = 60000 }) {
177
+ const timer = setInterval(() => {
178
+ distiller.tick().catch(() => {
179
+ // tick() already logs its own failures; a rejected promise must not become
180
+ // an unhandled rejection that takes the host down.
181
+ })
182
+ }, intervalMs)
183
+ // Do not wait a whole interval after activation to notice a rule that is
184
+ // already due (a restart with a threshold already met).
185
+ const first = setTimeout(() => {
186
+ distiller.tick().catch(() => {})
187
+ }, 5000)
188
+ if (typeof timer.unref === 'function') timer.unref()
189
+ if (typeof first.unref === 'function') first.unref()
190
+ return () => {
191
+ clearInterval(timer)
192
+ clearTimeout(first)
193
+ }
194
+ }