@ilaomianyang/dsh-memory-graph 0.11.0 → 0.12.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 +19 -0
- package/README.en.md +52 -12
- package/README.md +52 -12
- package/client.js +432 -0
- package/docs/update-settings.png +0 -0
- package/index.js +42 -3
- package/package.json +2 -2
- package/src/auto.js +68 -7
- package/src/config.js +37 -0
- package/src/history.js +27 -0
- package/src/schema.js +55 -0
- package/src/tools.js +24 -1
- package/src/update.js +406 -0
- package/src/vault.js +1 -1
- package/test/auto.mjs +68 -0
- package/test/client.mjs +15 -5
- package/test/update.mjs +401 -0
package/src/auto.js
CHANGED
|
@@ -32,14 +32,30 @@ export function inQuietHours(window, now) {
|
|
|
32
32
|
* Should this tick run a distillation? Pure, so the rules are readable in one
|
|
33
33
|
* place and testable without a clock.
|
|
34
34
|
*
|
|
35
|
+
* Three rules, in the order they are asked:
|
|
36
|
+
*
|
|
37
|
+
* 1. **catch-up** — a pile bigger than one batch is a queue, not a schedule. It
|
|
38
|
+
* runs every `catchUpEveryMinutes` until the pile is smaller than a batch, and
|
|
39
|
+
* stops for the day at `maxBatchesPerDay`. This is the rule that lets an
|
|
40
|
+
* imported history drain itself overnight without a person pressing anything,
|
|
41
|
+
* and the daily cap is what keeps it from spending without a ceiling.
|
|
42
|
+
* 2. **schedule** — `everyMinutes` since the last run (or since the clock
|
|
43
|
+
* started).
|
|
44
|
+
* 3. **threshold** — `minEpisodes` waiting.
|
|
45
|
+
*
|
|
46
|
+
* The gap (`minGapMinutes`) sits between 1 and 2/3: catch-up measures its own
|
|
47
|
+
* cadence, while the other two answers still respect the gap a person configured.
|
|
48
|
+
*
|
|
35
49
|
* @param {object} input
|
|
36
50
|
* @param {object} input.auto the `auto` config group
|
|
37
51
|
* @param {number} input.now epoch ms
|
|
38
52
|
* @param {number|null} input.lastAttemptAt epoch ms of the previous attempt
|
|
39
53
|
* @param {number} input.pending undistilled episodes waiting
|
|
54
|
+
* @param {number} [input.batchSize] episodes one run distils
|
|
55
|
+
* @param {number} [input.batchesToday] automatic batches already run today
|
|
40
56
|
* @returns {{due: boolean, rule: string|null, reason: string}}
|
|
41
57
|
*/
|
|
42
|
-
export function autoDecision({ auto, now, lastAttemptAt = null, startedAt = null, pending = 0 }) {
|
|
58
|
+
export function autoDecision({ auto, now, lastAttemptAt = null, startedAt = null, pending = 0, batchSize = 40, batchesToday = 0 }) {
|
|
43
59
|
if (auto?.enabled !== true) return { due: false, rule: null, reason: 'auto is off' }
|
|
44
60
|
if (pending <= 0) return { due: false, rule: null, reason: 'nothing to distill' }
|
|
45
61
|
if (inQuietHours(auto.quietHours, now)) return { due: false, rule: null, reason: 'quiet hours' }
|
|
@@ -55,6 +71,24 @@ export function autoDecision({ auto, now, lastAttemptAt = null, startedAt = null
|
|
|
55
71
|
const clock = lastAttemptAt ?? startedAt
|
|
56
72
|
const sinceClock = clock === null ? null : now - clock
|
|
57
73
|
|
|
74
|
+
// Catch-up first: its cadence is its own, and it is the only rule that can be
|
|
75
|
+
// due while the gap configured for the others has not elapsed.
|
|
76
|
+
if (auto.catchUp === true && pending >= Math.max(1, batchSize)) {
|
|
77
|
+
const cap = auto.maxBatchesPerDay ?? 0
|
|
78
|
+
if (cap > 0 && batchesToday >= cap) {
|
|
79
|
+
return { due: false, rule: null, reason: `今日已跑 ${batchesToday} 批,达到上限 ${cap} · daily batch cap reached` }
|
|
80
|
+
}
|
|
81
|
+
const every = Math.max(1, auto.catchUpEveryMinutes ?? 10) * MINUTE
|
|
82
|
+
if (sinceRun === null || sinceRun >= every) {
|
|
83
|
+
return {
|
|
84
|
+
due: true,
|
|
85
|
+
rule: 'catch-up',
|
|
86
|
+
reason: `还有 ${pending} 条待蒸馏,今天第 ${batchesToday + 1} 批${cap > 0 ? ` / 上限 ${cap}` : ''}`,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { due: false, rule: null, reason: `距上一批不到 ${auto.catchUpEveryMinutes ?? 10} 分钟 · catch-up gap` }
|
|
90
|
+
}
|
|
91
|
+
|
|
58
92
|
const gap = (auto.minGapMinutes ?? 0) * MINUTE
|
|
59
93
|
if (gap > 0 && sinceRun !== null && sinceRun < gap) {
|
|
60
94
|
return { due: false, rule: null, reason: 'the gap between runs has not elapsed' }
|
|
@@ -85,6 +119,8 @@ export function createAutoDistiller({ cfg, vault, run, log = () => {}, now = ()
|
|
|
85
119
|
let lastAttemptAt = null
|
|
86
120
|
let startedAt = null
|
|
87
121
|
let lastReport = null
|
|
122
|
+
let day = null
|
|
123
|
+
let batchesToday = 0
|
|
88
124
|
|
|
89
125
|
async function loadState() {
|
|
90
126
|
if (loaded) return
|
|
@@ -95,20 +131,34 @@ export function createAutoDistiller({ cfg, vault, run, log = () => {}, now = ()
|
|
|
95
131
|
const start = Number(state?.startedAt)
|
|
96
132
|
lastAttemptAt = Number.isFinite(attempt) && attempt > 0 ? attempt : null
|
|
97
133
|
startedAt = Number.isFinite(start) && start > 0 ? start : null
|
|
134
|
+
day = typeof state?.batchDay === 'string' ? state.batchDay : null
|
|
135
|
+
const count = Number(state?.batchesToday)
|
|
136
|
+
batchesToday = Number.isFinite(count) && count > 0 ? count : 0
|
|
98
137
|
} catch (error) {
|
|
99
138
|
log('warn', `自动蒸馏:读不了 .auto-state.json · auto: could not read .auto-state.json: ${error?.message ?? error}`)
|
|
100
139
|
}
|
|
101
140
|
}
|
|
102
141
|
|
|
142
|
+
/** Today's local date, the key the daily batch cap resets on. */
|
|
143
|
+
function todayKey(at) {
|
|
144
|
+
const date = new Date(at)
|
|
145
|
+
const pad = (value) => String(Math.abs(value)).padStart(2, '0')
|
|
146
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
147
|
+
}
|
|
148
|
+
|
|
103
149
|
/** One decision, and at most one run. Returns what it decided, for the tests. */
|
|
104
150
|
async function tick() {
|
|
105
151
|
const auto = cfg.auto ?? {}
|
|
106
152
|
await loadState()
|
|
107
153
|
if (auto.enabled !== true) return { due: false, rule: null, reason: 'auto is off', ran: false }
|
|
108
154
|
|
|
155
|
+
const batchSize = Math.max(1, cfg.extractor?.maxEpisodesPerRun ?? 40)
|
|
109
156
|
let pending = 0
|
|
110
157
|
try {
|
|
111
|
-
|
|
158
|
+
// Ask for one batch more than a batch: the decision needs to know whether a
|
|
159
|
+
// *full* batch is waiting (catch-up) and whether the threshold is met, and
|
|
160
|
+
// one extra row answers both without a second read.
|
|
161
|
+
const batch = await vault.readUndistilled(Math.max(1, auto.minEpisodes ?? 0, batchSize))
|
|
112
162
|
pending = Array.isArray(batch) ? batch.length : 0
|
|
113
163
|
} catch (error) {
|
|
114
164
|
log('warn', `自动蒸馏:数不了还没蒸馏的情节 · auto: could not count undistilled episodes: ${error?.message ?? error}`)
|
|
@@ -130,14 +180,25 @@ export function createAutoDistiller({ cfg, vault, run, log = () => {}, now = ()
|
|
|
130
180
|
}
|
|
131
181
|
}
|
|
132
182
|
|
|
133
|
-
|
|
134
|
-
|
|
183
|
+
// The daily cap is per calendar day, and a new day resets it — in memory now
|
|
184
|
+
// and on disk with the next write, so a restart cannot lose the count either.
|
|
185
|
+
const today = todayKey(at)
|
|
186
|
+
if (day !== today) {
|
|
187
|
+
day = today
|
|
188
|
+
batchesToday = 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const decision = autoDecision({ auto, now: at, lastAttemptAt, startedAt, pending, batchSize, batchesToday })
|
|
192
|
+
if (!decision.due) return { ...decision, pending, batchesToday, ran: false }
|
|
135
193
|
|
|
136
194
|
// Arm the gap before the call: a slow or failing run must not be retried on
|
|
137
195
|
// the next tick.
|
|
138
196
|
lastAttemptAt = at
|
|
197
|
+
// Counted before the call, like the gap: a run that fails or finds another one
|
|
198
|
+
// in flight still spent this batch's slot, so the daily cap stays a real cap.
|
|
199
|
+
if (decision.rule === 'catch-up') batchesToday += 1
|
|
139
200
|
try {
|
|
140
|
-
await vault.writeAutoState?.({ lastAttemptAt: at })
|
|
201
|
+
await vault.writeAutoState?.(decision.rule === 'catch-up' ? { lastAttemptAt: at, batchDay: today, batchesToday } : { lastAttemptAt: at })
|
|
141
202
|
} catch (error) {
|
|
142
203
|
log('warn', `自动蒸馏:没能记下这次尝试 · auto: could not record the attempt: ${error?.message ?? error}`)
|
|
143
204
|
}
|
|
@@ -158,12 +219,12 @@ export function createAutoDistiller({ cfg, vault, run, log = () => {}, now = ()
|
|
|
158
219
|
`自动蒸馏 auto (${decision.rule}): ${report?.message ?? report?.reason ?? '没有运行 · did not run'}`,
|
|
159
220
|
)
|
|
160
221
|
}
|
|
161
|
-
return { ...decision, pending, ran: true, report }
|
|
222
|
+
return { ...decision, pending, batchesToday, ran: true, report }
|
|
162
223
|
}
|
|
163
224
|
|
|
164
225
|
/** What the ticker knows, for a diagnostics line or a test. */
|
|
165
226
|
function state() {
|
|
166
|
-
return { lastAttemptAt, lastReport }
|
|
227
|
+
return { lastAttemptAt, lastReport, batchesToday, day }
|
|
167
228
|
}
|
|
168
229
|
|
|
169
230
|
return { tick, state }
|
package/src/config.js
CHANGED
|
@@ -114,6 +114,34 @@ const DEFAULTS = {
|
|
|
114
114
|
quietHours: '',
|
|
115
115
|
/** Project tag for automatic cards; empty uses the newest episode's project. */
|
|
116
116
|
project: '',
|
|
117
|
+
/**
|
|
118
|
+
* Catch-up mode: instead of waiting for the schedule, keep running a batch
|
|
119
|
+
* every `catchUpEveryMinutes` while a full batch is still waiting — and stop
|
|
120
|
+
* at `maxBatchesPerDay`. This is what turns a backlog (an imported history,
|
|
121
|
+
* a week of sessions) into something that drains itself without a person
|
|
122
|
+
* pressing a button, while still capping what it can spend in one day.
|
|
123
|
+
*/
|
|
124
|
+
catchUp: false,
|
|
125
|
+
/** Minutes between catch-up batches. */
|
|
126
|
+
catchUpEveryMinutes: 10,
|
|
127
|
+
/** Most automatic batches in one calendar day; 0 turns the daily cap off. */
|
|
128
|
+
maxBatchesPerDay: 6,
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Update checking. The plugin asks the registry what the newest version is and
|
|
133
|
+
* compares it with the version it is running; it never installs unless asked
|
|
134
|
+
* (`autoInstall`) or a person presses the button. One HTTPS GET, no query
|
|
135
|
+
* string, nothing about the vault or the user in it.
|
|
136
|
+
*/
|
|
137
|
+
update: {
|
|
138
|
+
enabled: true,
|
|
139
|
+
/** Hours a check result stays good for. */
|
|
140
|
+
checkEveryHours: 12,
|
|
141
|
+
/** Install a newer version at startup without being asked. */
|
|
142
|
+
autoInstall: false,
|
|
143
|
+
/** Registry to ask; empty uses the plugin manager's, then npm's own. */
|
|
144
|
+
registry: '',
|
|
117
145
|
},
|
|
118
146
|
}
|
|
119
147
|
|
|
@@ -241,6 +269,14 @@ export function resolveConfig(raw) {
|
|
|
241
269
|
// A malformed window must not disable the rule or, worse, silence it forever:
|
|
242
270
|
// parse what is parseable and fall back to "no quiet hours".
|
|
243
271
|
cfg.auto.quietHours = parseQuietHours(cfg.auto.quietHours) === null ? '' : String(cfg.auto.quietHours).trim()
|
|
272
|
+
cfg.auto.catchUp = cfg.auto.catchUp === true
|
|
273
|
+
cfg.auto.catchUpEveryMinutes = int(cfg.auto.catchUpEveryMinutes, 10, 1, 1440)
|
|
274
|
+
cfg.auto.maxBatchesPerDay = int(cfg.auto.maxBatchesPerDay, 6, 0, 200)
|
|
275
|
+
|
|
276
|
+
cfg.update.enabled = cfg.update.enabled !== false
|
|
277
|
+
cfg.update.checkEveryHours = int(cfg.update.checkEveryHours, 12, 1, 24 * 30)
|
|
278
|
+
cfg.update.autoInstall = cfg.update.autoInstall === true
|
|
279
|
+
cfg.update.registry = String(cfg.update.registry || '').trim().replace(/\/+$/, '')
|
|
244
280
|
|
|
245
281
|
return cfg
|
|
246
282
|
}
|
|
@@ -276,5 +312,6 @@ export function paths(vaultDir) {
|
|
|
276
312
|
audit: path.join(vaultDir, 'audit'),
|
|
277
313
|
distillState: path.join(vaultDir, '.distill-state.json'),
|
|
278
314
|
autoState: path.join(vaultDir, '.auto-state.json'),
|
|
315
|
+
updateState: path.join(vaultDir, '.update-state.json'),
|
|
279
316
|
}
|
|
280
317
|
}
|
package/src/history.js
CHANGED
|
@@ -421,6 +421,32 @@ export async function runBackfill({
|
|
|
421
421
|
onProgress?.(report)
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
+
// Views last, and only when something changed: `README.md` and `graph.html` are
|
|
425
|
+
// rendered from the cards, so an import that adds episodes but no cards changes
|
|
426
|
+
// nothing on either. The *reason* to do it here is that a backfill is the one
|
|
427
|
+
// operation that can add hundreds of episodes after the last render, and a stale
|
|
428
|
+
// view is indistinguishable from an empty vault when a person opens the file.
|
|
429
|
+
let refreshed = false
|
|
430
|
+
const previous = { readme: true, graph: true }
|
|
431
|
+
if (!dryRun && imported > 0) {
|
|
432
|
+
for (const [key, render] of [
|
|
433
|
+
['readme', vault.regenerateReadme],
|
|
434
|
+
['graph', vault.regenerateGraph],
|
|
435
|
+
]) {
|
|
436
|
+
if (typeof render !== 'function') {
|
|
437
|
+
previous[key] = false
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
await render.call(vault)
|
|
442
|
+
} catch {
|
|
443
|
+
// A view that will not render must not lose the episodes that just landed.
|
|
444
|
+
previous[key] = false
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
refreshed = Object.values(previous).some(Boolean)
|
|
448
|
+
}
|
|
449
|
+
|
|
424
450
|
return {
|
|
425
451
|
dir,
|
|
426
452
|
dryRun,
|
|
@@ -430,6 +456,7 @@ export async function runBackfill({
|
|
|
430
456
|
imported,
|
|
431
457
|
duplicates,
|
|
432
458
|
exhausted,
|
|
459
|
+
refreshed,
|
|
433
460
|
malformed: sessions.reduce((n, s) => n + s.malformed, 0),
|
|
434
461
|
failed: sessions.filter((s) => s.failure !== null).length,
|
|
435
462
|
ms: Date.now() - started,
|
package/src/schema.js
CHANGED
|
@@ -292,6 +292,60 @@ const auto = z.object({
|
|
|
292
292
|
.default('')
|
|
293
293
|
.description('自动蒸馏记到哪个项目;空 = 最近情节的项目 · project for automatic cards; empty = the newest episode\'s project'),
|
|
294
294
|
),
|
|
295
|
+
catchUp: live(
|
|
296
|
+
z
|
|
297
|
+
.boolean()
|
|
298
|
+
.default(false)
|
|
299
|
+
.description('追平模式:只要还攒着一整批就按自己的节奏一批批蒸下去 · catch-up: keep distilling batch after batch while a full batch is still waiting'),
|
|
300
|
+
),
|
|
301
|
+
catchUpEveryMinutes: live(
|
|
302
|
+
z
|
|
303
|
+
.number()
|
|
304
|
+
.min(1)
|
|
305
|
+
.max(1440)
|
|
306
|
+
.step(1)
|
|
307
|
+
.default(10)
|
|
308
|
+
.description('追平模式每批之间隔多少分钟 · minutes between catch-up batches'),
|
|
309
|
+
),
|
|
310
|
+
maxBatchesPerDay: live(
|
|
311
|
+
z
|
|
312
|
+
.number()
|
|
313
|
+
.min(0)
|
|
314
|
+
.max(200)
|
|
315
|
+
.step(1)
|
|
316
|
+
.default(6)
|
|
317
|
+
.description('一天最多自动蒸几批;0 = 不限 · most automatic batches per day; 0 lifts the cap'),
|
|
318
|
+
),
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const update = z.object({
|
|
322
|
+
enabled: live(
|
|
323
|
+
z
|
|
324
|
+
.boolean()
|
|
325
|
+
.default(true)
|
|
326
|
+
.description('定期检查 npm 上有没有新版本(只查,不装)· check npm for a newer version (checks only, never installs)'),
|
|
327
|
+
),
|
|
328
|
+
checkEveryHours: live(
|
|
329
|
+
z
|
|
330
|
+
.number()
|
|
331
|
+
.min(1)
|
|
332
|
+
.max(720)
|
|
333
|
+
.step(1)
|
|
334
|
+
.default(12)
|
|
335
|
+
.description('查到的结果保持多久 · hours a check result stays good for'),
|
|
336
|
+
),
|
|
337
|
+
autoInstall: live(
|
|
338
|
+
z
|
|
339
|
+
.boolean()
|
|
340
|
+
.default(false)
|
|
341
|
+
.description('启动时发现新版本就自动装(link: 本地安装会拒绝)· install a newer version at startup; a link: install is refused'),
|
|
342
|
+
),
|
|
343
|
+
registry: live(
|
|
344
|
+
z
|
|
345
|
+
.string()
|
|
346
|
+
.default('')
|
|
347
|
+
.description('用哪个 npm 源查版本;空 = 宿主的源,再退到 npmjs · registry to ask; empty = the harness\'s, then npmjs'),
|
|
348
|
+
),
|
|
295
349
|
})
|
|
296
350
|
|
|
297
351
|
export const Config = z.object({
|
|
@@ -317,6 +371,7 @@ export const Config = z.object({
|
|
|
317
371
|
privacy,
|
|
318
372
|
extractor,
|
|
319
373
|
auto,
|
|
374
|
+
update,
|
|
320
375
|
retrieval,
|
|
321
376
|
})
|
|
322
377
|
|
package/src/tools.js
CHANGED
|
@@ -57,6 +57,27 @@ function formatHits(hits) {
|
|
|
57
57
|
*/
|
|
58
58
|
|
|
59
59
|
/** One line describing what a distill call would do right now, for memory_stats. */
|
|
60
|
+
/**
|
|
61
|
+
* The version line for memory_stats.
|
|
62
|
+
*
|
|
63
|
+
* Deliberately silent about a newer version when the check has not run yet: "no
|
|
64
|
+
* news" must not read as "you are up to date", and a check is an outbound request
|
|
65
|
+
* that a person did not necessarily just make.
|
|
66
|
+
*/
|
|
67
|
+
function describeUpdate(getUpdate) {
|
|
68
|
+
if (typeof getUpdate !== 'function') return '版本 version: 更新检查不可用 · update check unavailable'
|
|
69
|
+
let state = null
|
|
70
|
+
try {
|
|
71
|
+
state = getUpdate()
|
|
72
|
+
} catch {
|
|
73
|
+
return '版本 version: 更新检查不可用 · update check unavailable'
|
|
74
|
+
}
|
|
75
|
+
if (state === null || typeof state !== 'object' || state.current === null) return '版本 version: 未知 · unknown'
|
|
76
|
+
const current = `版本 version: \`${state.current}\``
|
|
77
|
+
if (state.latest === null) return `${current} · 还没有检查过更新 · no update check yet`
|
|
78
|
+
if (state.latest === state.current) return `${current} · 已是最新 · latest`
|
|
79
|
+
return `${current} · 有新版本 \`${state.latest}\`(设置页可更新)· newer version available`
|
|
80
|
+
}
|
|
60
81
|
function describeExtractor(ctx, cfg) {
|
|
61
82
|
if (!cfg.extractor.enabled) return 'extractor: off (nothing calls a model)'
|
|
62
83
|
if (cfg.extractor.transport === 'http') {
|
|
@@ -73,7 +94,7 @@ function describeExtractor(ctx, cfg) {
|
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
export function registerTools(ctx, { cfg, vault, redactor, runner = null }) {
|
|
97
|
+
export function registerTools(ctx, { cfg, vault, redactor, runner = null, getUpdate = null }) {
|
|
77
98
|
const disposers = []
|
|
78
99
|
|
|
79
100
|
/**
|
|
@@ -525,6 +546,7 @@ export function registerTools(ctx, { cfg, vault, redactor, runner = null }) {
|
|
|
525
546
|
describeExtractor(ctx, cfg),
|
|
526
547
|
`privacy rules: ${stats.privacy.rules}`,
|
|
527
548
|
`views: README.md and graph.html in the vault root (open the HTML directly, no server needed)`,
|
|
549
|
+
describeUpdate(getUpdate),
|
|
528
550
|
].join('\n')
|
|
529
551
|
return { ...stats, text }
|
|
530
552
|
},
|
|
@@ -575,6 +597,7 @@ export function registerTools(ctx, { cfg, vault, redactor, runner = null }) {
|
|
|
575
597
|
}
|
|
576
598
|
if (report.imported > 0 && !dryRun) {
|
|
577
599
|
lines.push('这些情节还没有变成卡片:蒸馏一次(memory_distill 或设置页的「蒸馏一次」)才会进入检索层。')
|
|
600
|
+
if (report.refreshed === true) lines.push('README.md 和 graph.html 已经按新情节重画过一次。')
|
|
578
601
|
}
|
|
579
602
|
return { ...report, text: lines.join('\n') }
|
|
580
603
|
},
|