@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/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
- const batch = await vault.readUndistilled(Math.max(1, auto.minEpisodes ?? 0, cfg.extractor?.maxEpisodesPerRun ?? 40))
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
- const decision = autoDecision({ auto, now: at, lastAttemptAt, startedAt, pending })
134
- if (!decision.due) return { ...decision, pending, ran: false }
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
  },