@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.
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.en.md +466 -0
- package/README.md +459 -0
- package/client.js +1953 -0
- package/cordis.patch.yml +13 -0
- package/docs/auto-settings.png +0 -0
- package/docs/distill-button.png +0 -0
- package/docs/graph-dark.png +0 -0
- package/docs/graph-light.png +0 -0
- package/docs/session-tab-cards.png +0 -0
- package/docs/session-tab-open.png +0 -0
- package/docs/session-tab.png +0 -0
- package/docs/vault-panel.png +0 -0
- package/eval/REPORT.md +437 -0
- package/eval/questions.json +277 -0
- package/eval/run.mjs +460 -0
- package/eval/seed.mjs +489 -0
- package/icon.svg +26 -0
- package/index.js +243 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -0
- package/src/auto.js +194 -0
- package/src/capture.js +237 -0
- package/src/config.js +280 -0
- package/src/distillapi.js +30 -0
- package/src/distillrun.js +106 -0
- package/src/extract.js +660 -0
- package/src/graph.js +1582 -0
- package/src/graphdata.js +475 -0
- package/src/http.js +32 -0
- package/src/pageapi.js +40 -0
- package/src/privacy.js +78 -0
- package/src/schema.js +323 -0
- package/src/search.js +317 -0
- package/src/tools.js +534 -0
- package/src/vault.js +888 -0
- package/test/auto.mjs +295 -0
- package/test/capture.mjs +264 -0
- package/test/client.mjs +1035 -0
- package/test/distillapi.mjs +212 -0
- package/test/extract.mjs +660 -0
- package/test/graphdata.mjs +295 -0
- package/test/live-supersede.mjs +78 -0
- package/test/live.mjs +156 -0
- package/test/page.mjs +116 -0
- package/test/schema.mjs +256 -0
- package/test/smoke.mjs +428 -0
- 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
package/locale/zh.json
ADDED
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
|
+
}
|