@ilaomianyang/dsh-memory-graph 0.10.4 → 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,23 +131,37 @@ 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
- log('warn', `auto: could not read .auto-state.json: ${error?.message ?? error}`)
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
- log('warn', `auto: could not count undistilled episodes: ${error?.message ?? error}`)
164
+ log('warn', `自动蒸馏:数不了还没蒸馏的情节 · auto: could not count undistilled episodes: ${error?.message ?? error}`)
115
165
  return { due: false, rule: null, reason: 'the vault could not be read', ran: false }
116
166
  }
117
167
 
@@ -126,41 +176,55 @@ export function createAutoDistiller({ cfg, vault, run, log = () => {}, now = ()
126
176
  try {
127
177
  await vault.writeAutoState?.({ startedAt: at })
128
178
  } catch (error) {
129
- log('warn', `auto: could not start the schedule clock: ${error?.message ?? error}`)
179
+ log('warn', `自动蒸馏:没能启动计划时钟 · auto: could not start the schedule clock: ${error?.message ?? error}`)
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
- log('warn', `auto: could not record the attempt: ${error?.message ?? error}`)
203
+ log('warn', `自动蒸馏:没能记下这次尝试 · auto: could not record the attempt: ${error?.message ?? error}`)
143
204
  }
144
205
 
145
206
  const report = await run({ project: auto.project ? String(auto.project) : undefined })
146
207
  lastReport = report
147
208
  if (report?.busy === true) {
148
- log('info', 'auto: skipped, a distillation is already running')
209
+ log('info', '自动蒸馏:跳过,已经有一次蒸馏在跑 · auto: skipped, a distillation is already running')
149
210
  } else if (report?.ok === true) {
150
211
  log(
151
212
  'info',
152
- `auto (${decision.rule}): read ${report.episodes} episode(s) → wrote ${report.written} card(s)` +
213
+ `自动蒸馏 auto (${decision.rule}): read ${report.episodes} episode(s) → wrote ${report.written} card(s)` +
153
214
  (report.model ? ` · ${report.model}` : ''),
154
215
  )
155
216
  } else {
156
- log('warn', `auto (${decision.rule}): ${report?.message ?? report?.reason ?? 'did not run'}`)
217
+ log(
218
+ 'warn',
219
+ `自动蒸馏 auto (${decision.rule}): ${report?.message ?? report?.reason ?? '没有运行 · did not run'}`,
220
+ )
157
221
  }
158
- return { ...decision, pending, ran: true, report }
222
+ return { ...decision, pending, batchesToday, ran: true, report }
159
223
  }
160
224
 
161
225
  /** What the ticker knows, for a diagnostics line or a test. */
162
226
  function state() {
163
- return { lastAttemptAt, lastReport }
227
+ return { lastAttemptAt, lastReport, batchesToday, day }
164
228
  }
165
229
 
166
230
  return { tick, state }
package/src/capture.js CHANGED
@@ -4,117 +4,24 @@
4
4
  * Deliberately model-free. Every turn is appended as it closes, so the "never
5
5
  * loses anything" guarantee holds even while the extractor is unconfigured,
6
6
  * offline, or out of quota. Distillation happens later and separately.
7
+ *
8
+ * What counts as a real message, how it is truncated and how it is redacted
9
+ * lives in `turns.js`, shared with the history importer — see the note there for
10
+ * why there is exactly one copy of that logic.
7
11
  */
8
- import { nowIso } from './vault.js'
12
+ import { createTurnAssembler, projectOf } from './turns.js'
9
13
 
10
14
  const FLUSH_AFTER_MS = 90_000
11
- const MAX_ASSISTANT_PARTS = 6
12
-
13
- function collectText(node, depth = 0, seen = new Set(), out = []) {
14
- if (node === null || node === undefined || depth > 4) return out
15
- if (typeof node === 'string') return out
16
- if (Array.isArray(node)) {
17
- for (const item of node) collectText(item, depth + 1, seen, out)
18
- return out
19
- }
20
- if (typeof node !== 'object') return out
21
- if (seen.has(node)) return out
22
- seen.add(node)
23
-
24
- if (node.type === 'text' && typeof node.text === 'string') {
25
- out.push(node.text)
26
- return out
27
- }
28
- for (const key of ['content', 'message', 'text', 'parts', 'blocks']) {
29
- if (key in node) collectText(node[key], depth + 1, seen, out)
30
- }
31
- return out
32
- }
33
-
34
- function eventText(event) {
35
- const parts = collectText(event?.data)
36
- return parts.join('\n').replace(/\n{3,}/g, '\n\n').trim()
37
- }
38
-
39
- function projectOf(session, fallback) {
40
- const cwd = session?.header?.cwd ?? session?.meta?.cwd ?? session?.cwd
41
- if (typeof cwd === 'string' && cwd.trim() !== '') {
42
- // Both separators, whatever platform this is running on: the path comes from
43
- // the session header, which may well have been written on the other OS.
44
- const parts = cwd.replace(/[\\/]+$/, '').split(/[\\/]/)
45
- const base = parts[parts.length - 1] ?? ''
46
- if (base !== '') return base
47
- }
48
- return fallback
49
- }
50
-
51
- function truncate(text, max) {
52
- const value = String(text ?? '').trim()
53
- if (value.length <= max) return value
54
- return `${value.slice(0, max)}…`
55
- }
56
15
 
57
16
  export function registerCapture(ctx, { cfg, vault, redactor }) {
58
- const buffers = new Map()
17
+ const assembler = createTurnAssembler({ cfg, redactor })
59
18
  const disposers = []
60
19
  const timers = new Map()
61
20
 
62
- // Both the switch and the skip list are editable while the plugin runs (they
63
- // are `.volatile()` in the schema), so neither is baked in here: the switch is
64
- // read per event, and the compiled rules are rebuilt only when the list's text
65
- // actually changes — a `new RegExp` on every token would be pure waste.
66
- let skipKey
67
- let skipRules = []
68
-
69
- function compiledSkipRules() {
70
- const list = cfg.capture.skipPatterns ?? []
71
- const key = list.join('\u0000')
72
- if (key !== skipKey) {
73
- skipKey = key
74
- skipRules = list.flatMap((pattern) => {
75
- try {
76
- return [new RegExp(pattern, 'i')]
77
- } catch {
78
- return []
79
- }
80
- })
81
- }
82
- return skipRules
83
- }
84
-
85
- /** True when the "user" side is really a harness injection, not a person typing. */
86
- const injected = (text) => text !== '' && compiledSkipRules().some((re) => re.test(text))
87
-
88
- /**
89
- * Delimited harness blocks come off before the patterns are consulted.
90
- *
91
- * They are the one kind of injection whose extent is knowable, so removing them
92
- * keeps the person's own words when a real message happens to carry one — the
93
- * alternative (dropping the whole message) loses the part that was worth
94
- * keeping. Undelimited blocks (a runtime snapshot) are handled by the patterns.
95
- */
96
- function stripInjectedBlocks(text) {
97
- return String(text ?? '')
98
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
99
- .replace(/[ \t]+\n/g, '\n')
100
- .replace(/\n{3,}/g, '\n\n')
101
- .trim()
102
- }
103
-
104
21
  const idOf = (session) => String(session?.id ?? session?.header?.id ?? 'unknown')
105
22
 
106
- function bufferOf(session) {
107
- const id = idOf(session)
108
- let buffer = buffers.get(id)
109
- if (!buffer) {
110
- buffer = { project: projectOf(session, cfg.defaultProject), user: '', assistant: [], tools: 0, turn: null }
111
- buffers.set(id, buffer)
112
- }
113
- return { id, buffer }
114
- }
115
-
116
23
  function drop(id) {
117
- buffers.delete(id)
24
+ assembler.forget(id)
118
25
  const timer = timers.get(id)
119
26
  if (timer) {
120
27
  clearTimeout(timer)
@@ -123,33 +30,9 @@ export function registerCapture(ctx, { cfg, vault, redactor }) {
123
30
  }
124
31
 
125
32
  async function flush(id) {
126
- const buffer = buffers.get(id)
127
- if (!buffer) return
128
- const toolCount = buffer.tools
129
- const user = truncate(buffer.user, cfg.capture.userChars)
130
- const assistant = truncate(buffer.assistant.join('\n'), cfg.capture.assistantChars)
131
-
132
- // Every episode is anchored to something the user actually said. A turn with
133
- // no user side is context noise, and one too short to mean anything — such as
134
- // "ok" — would only fill the vault with lines the extractor cannot use.
135
- if (user === '' || user.length + assistant.length < cfg.capture.minChars) {
136
- drop(id)
137
- return
138
- }
139
-
33
+ const episode = assembler.flush(id)
140
34
  drop(id)
141
-
142
- const redactedUser = redactor.redact(user).text
143
- const redactedAssistant = redactor.redact(assistant).text
144
- await vault.appendEpisode({
145
- t: nowIso(),
146
- project: buffer.project,
147
- session: id,
148
- turn: buffer.turn,
149
- user: redactedUser,
150
- assistant: redactedAssistant,
151
- ...(cfg.capture.includeToolOutput && toolCount > 0 ? { tools: toolCount } : {}),
152
- })
35
+ if (episode) await vault.appendEpisode(episode)
153
36
  }
154
37
 
155
38
  function schedule(id) {
@@ -164,65 +47,42 @@ export function registerCapture(ctx, { cfg, vault, redactor }) {
164
47
 
165
48
  disposers.push(
166
49
  ctx.on('session/event', (session, event) => {
167
- // The switch is read here, not at registration: turning capture off in
168
- // 设置 must stop writes immediately without unregistering the listener.
50
+ // The switch is read inside the assembler, per event: turning capture off
51
+ // in 设置 must stop writes immediately without unregistering the listener.
169
52
  if (!cfg.capture.enabled) return
170
53
 
171
- const type = event?.type
54
+ const id = idOf(session)
55
+ const episodes = assembler.push({
56
+ sessionId: id,
57
+ project: projectOf(session, cfg.defaultProject),
58
+ event,
59
+ })
172
60
 
173
- // A turn is the natural write unit: flush it as soon as it closes, so an
174
- // episode exists even if the next turn never comes.
175
- if (type === 'turn/end') {
176
- const id = idOf(session)
177
- const buffer = buffers.get(id)
178
- if (buffer) {
179
- if (typeof event?.data?.turn === 'number') buffer.turn = event.data.turn
180
- flush(id).catch(() => {})
61
+ if (episodes.length > 0) {
62
+ for (const episode of episodes) {
63
+ vault.appendEpisode(episode).catch(() => {})
181
64
  }
182
- return
183
65
  }
184
66
 
185
- if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return
186
-
187
- if (type === 'user/message') {
188
- // The harness delivers runtime snapshots, skill catalogs, sampled time,
189
- // approval notices and compaction checkpoints through the same channel as
190
- // the person typing — and not always with a telling `source.kind`: a real
191
- // runtime snapshot has arrived here tagged `user` (it was captured as if
192
- // the person had typed it). So provenance can only *add* a skip, never
193
- // veto the patterns; both are consulted, and delimited blocks come off
194
- // first so a message that merely carried a reminder keeps its own words.
195
- const kind = event?.data?.source?.kind
196
- const text = stripInjectedBlocks(eventText(event))
197
- const synthetic = (typeof kind === 'string' && kind !== 'user') || text === '' || injected(text)
198
- if (synthetic) return
67
+ const type = event?.type
68
+ const relevant =
69
+ type === 'turn/end' || type === 'user/message' || type === 'assistant/message' || type === 'tool/result'
70
+ if (!relevant) return
199
71
 
200
- const id = idOf(session)
201
- const open = buffers.get(id)
202
- if (open && (open.user !== '' || open.assistant.length > 0)) {
203
- // A turn that never closed (interrupted, or an older harness): keep it.
204
- flush(id).catch(() => {})
72
+ // A turn is the natural write unit: flush it as soon as it closes, so an
73
+ // episode exists even if the next turn never comes. The timer is the safety
74
+ // net for a turn that never closes at all.
75
+ if (type === 'turn/end') {
76
+ const timer = timers.get(id)
77
+ if (timer) {
78
+ clearTimeout(timer)
79
+ timers.delete(id)
205
80
  }
206
-
207
- const { buffer } = bufferOf(session)
208
- buffer.user = text
209
- if (typeof event?.data?.turn === 'number') buffer.turn = event.data.turn
210
- schedule(id)
81
+ assembler.forget(id)
211
82
  return
212
83
  }
213
84
 
214
- const { id, buffer } = bufferOf(session)
215
-
216
- if (type === 'assistant/message') {
217
- const text = eventText(event)
218
- if (text !== '' && buffer.assistant.length < MAX_ASSISTANT_PARTS) buffer.assistant.push(text)
219
- if (typeof event?.data?.turn === 'number') buffer.turn = event.data.turn
220
- schedule(id)
221
- return
222
- }
223
-
224
- buffer.tools += 1
225
- schedule(id)
85
+ if (assembler.isOpen(id)) schedule(id)
226
86
  }),
227
87
  )
228
88
 
@@ -231,7 +91,9 @@ export function registerCapture(ctx, { cfg, vault, redactor }) {
231
91
  () => {
232
92
  for (const timer of timers.values()) clearTimeout(timer)
233
93
  timers.clear()
234
- for (const id of [...buffers.keys()]) flush(id).catch(() => {})
94
+ for (const episode of assembler.flushAll()) {
95
+ vault.appendEpisode(episode).catch(() => {})
96
+ }
235
97
  },
236
98
  ]
237
99
  }
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/distillapi.js CHANGED
@@ -23,7 +23,10 @@ export function createDistillHandler({ getRunner }) {
23
23
  return async function handleDistill() {
24
24
  const runner = getRunner()
25
25
  if (typeof runner?.run !== 'function') {
26
- return json({ ok: false, reason: 'unavailable', message: 'the distiller is not available' }, 500)
26
+ return json(
27
+ { ok: false, reason: 'unavailable', message: '蒸馏器不可用 · the distiller is not available' },
28
+ 500,
29
+ )
27
30
  }
28
31
  return respond(await runner.run())
29
32
  }
package/src/distillrun.js CHANGED
@@ -42,7 +42,11 @@ export function createDistillRunner(options) {
42
42
  */
43
43
  function run({ project, dryRun = false, signal } = {}) {
44
44
  if (inflight) {
45
- return Promise.resolve({ ok: false, busy: true, reason: 'A distillation is already running.' })
45
+ return Promise.resolve({
46
+ ok: false,
47
+ busy: true,
48
+ reason: '已有一次蒸馏在跑,等它结束 · A distillation is already running.',
49
+ })
46
50
  }
47
51
 
48
52
  const task = (async () => {
@@ -66,7 +70,7 @@ export function createDistillRunner(options) {
66
70
  try {
67
71
  await refreshViews()
68
72
  } catch (error) {
69
- log('warn', `distilled, but a vault view did not render: ${error?.message ?? error}`)
73
+ log('warn', `蒸馏完成了,但有一个记忆库视图没能渲染出来 · distilled, but a vault view did not render: ${error?.message ?? error}`)
70
74
  }
71
75
  }
72
76
  return {
@@ -85,7 +89,7 @@ export function createDistillRunner(options) {
85
89
  reason: report.reason ?? null,
86
90
  }
87
91
  } catch (error) {
88
- log('warn', `distillation failed: ${error?.message ?? error}`)
92
+ log('warn', `蒸馏失败 · distillation failed: ${error?.message ?? error}`)
89
93
  return {
90
94
  ok: false,
91
95
  busy: false,