@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/CHANGELOG.md +40 -0
- package/README.en.md +73 -12
- package/README.md +73 -12
- package/client.js +433 -1
- package/docs/update-settings.png +0 -0
- package/index.js +42 -3
- package/package.json +5 -3
- package/src/auto.js +78 -14
- package/src/capture.js +37 -175
- package/src/config.js +37 -0
- package/src/distillapi.js +4 -1
- package/src/distillrun.js +7 -3
- package/src/extract.js +42 -18
- package/src/graph.js +10 -5
- package/src/graphdata.js +10 -1
- package/src/history.js +467 -0
- package/src/pageapi.js +2 -2
- package/src/schema.js +55 -0
- package/src/tools.js +76 -2
- package/src/turns.js +265 -0
- package/src/update.js +406 -0
- package/src/vault.js +21 -11
- package/test/auto.mjs +68 -0
- package/test/client.mjs +15 -5
- package/test/fixtures/session.v4.jsonl.zstd +0 -0
- package/test/history.mjs +249 -0
- package/test/tools.mjs +2 -1
- 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,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',
|
|
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
|
-
log('warn',
|
|
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',
|
|
179
|
+
log('warn', `自动蒸馏:没能启动计划时钟 · auto: could not start the schedule clock: ${error?.message ?? error}`)
|
|
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
|
-
log('warn',
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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({
|
|
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',
|
|
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',
|
|
92
|
+
log('warn', `蒸馏失败 · distillation failed: ${error?.message ?? error}`)
|
|
89
93
|
return {
|
|
90
94
|
ok: false,
|
|
91
95
|
busy: false,
|