@aiwayds/dsh-topics-memory 0.13.1 → 0.15.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/README.md +5 -1
- package/README.zh.md +5 -1
- package/lib/commands.d.ts +8 -1
- package/lib/commands.js +75 -5
- package/lib/config.d.ts +32 -1
- package/lib/config.js +25 -1
- package/lib/consolidate.d.ts +138 -0
- package/lib/consolidate.js +571 -0
- package/lib/ilog.d.ts +18 -0
- package/lib/ilog.js +47 -0
- package/lib/index.js +58 -2
- package/lib/observer.js +6 -4
- package/lib/retrieval.d.ts +17 -1
- package/lib/retrieval.js +16 -0
- package/lib/service.d.ts +10 -1
- package/lib/service.js +78 -1
- package/lib/store.d.ts +11 -0
- package/lib/store.js +35 -1
- package/package.json +24 -24
- package/skills/dsh-topics-memory-config/SKILL.md +12 -3
- package/src/commands.ts +84 -5
- package/src/config.ts +27 -1
- package/src/consolidate.ts +639 -0
- package/src/ilog.ts +53 -0
- package/src/index.ts +56 -2
- package/src/observer.ts +6 -3
- package/src/retrieval.ts +28 -1
- package/src/service.ts +86 -1
- package/src/store.ts +38 -2
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidation lane — periodic LLM gardening over the topic pool itself.
|
|
3
|
+
*
|
|
4
|
+
* Where the distill lane turns observations into topics, this lane turns the
|
|
5
|
+
* EXISTING pool into a better one: merging near-duplicates, promoting drafts
|
|
6
|
+
* whose conclusions have settled, deprecating superseded entries, refreshing
|
|
7
|
+
* metadata (title/tags/triggers). Triggered by cadence at session start
|
|
8
|
+
* (`consolidate-cadence`: off | daily | 3d | 7d) or manually via
|
|
9
|
+
* `/topics consolidate`. The model route is shared with the distill lane
|
|
10
|
+
* (distill-provider/distill-model) — one route to configure, one caller
|
|
11
|
+
* instance injected.
|
|
12
|
+
*
|
|
13
|
+
* Safety model:
|
|
14
|
+
* - grounded proposals: clusters are built locally (lexical similarity over
|
|
15
|
+
* title+tags tokens), so the model only ever judges topics that already
|
|
16
|
+
* look alike and every op names its slugs verbatim from the bundle;
|
|
17
|
+
* - four op kinds only, and refresh may NOT touch the conclusion body —
|
|
18
|
+
* rewriting conclusions is merge's exclusive path, so a metadata refresh
|
|
19
|
+
* can never silently lose knowledge; no create: consolidation prunes,
|
|
20
|
+
* it never grows;
|
|
21
|
+
* - every write rides the normal saveTopic path (one git commit per change),
|
|
22
|
+
* so the whole run is one `git revert` away;
|
|
23
|
+
* - the cadence stamp (meta/consolidate-state.json) advances only when the
|
|
24
|
+
* model actually evaluated at least one cluster — a run that died on the
|
|
25
|
+
* first call leaves the stamp alone, and the next session start retries.
|
|
26
|
+
*
|
|
27
|
+
* @module consolidate
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as okf from './okf.ts'
|
|
31
|
+
import type { TopicsService } from './service.ts'
|
|
32
|
+
import { parseOps, type ModelCaller } from './distill.ts'
|
|
33
|
+
|
|
34
|
+
/** Below this pairwise Jaccard two topics never share a cluster. */
|
|
35
|
+
const CLUSTER_THRESHOLD = 0.3
|
|
36
|
+
/** Clusters are capped — a giant cluster drowns the model in candidates. */
|
|
37
|
+
export const MAX_CLUSTER_SIZE = 6
|
|
38
|
+
/** Model calls (≈ clusters) per run; the rest wait for the next cadence. */
|
|
39
|
+
const MAX_MODEL_CALLS_PER_RUN = 8
|
|
40
|
+
/** Hard cap on applied ops per run — consolidation lands in reviewable steps. */
|
|
41
|
+
const MAX_OPS_PER_RUN = 12
|
|
42
|
+
/** Conclusion chars fed per topic in a cluster payload. */
|
|
43
|
+
const CONCLUSION_SNIPPET_CHARS = 600
|
|
44
|
+
const CONSOLIDATE_MAX_TOKENS = 2500
|
|
45
|
+
|
|
46
|
+
export interface ConsolidateOp {
|
|
47
|
+
op: 'merge' | 'promote' | 'deprecate' | 'refresh'
|
|
48
|
+
/** promote/deprecate/refresh target. */
|
|
49
|
+
slug?: string
|
|
50
|
+
/** merge: the surviving slug. */
|
|
51
|
+
survivor?: string
|
|
52
|
+
/** merge: slugs absorbed INTO the survivor (≥ 1). */
|
|
53
|
+
merged?: string[]
|
|
54
|
+
title?: string
|
|
55
|
+
description?: string
|
|
56
|
+
tags?: string[]
|
|
57
|
+
triggers?: string[]
|
|
58
|
+
/** merge only (refresh may not rewrite conclusions). */
|
|
59
|
+
conclusion?: string
|
|
60
|
+
reason?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ConsolidateAction {
|
|
64
|
+
kind: ConsolidateOp['op']
|
|
65
|
+
/** merge: survivor; others: the target slug. */
|
|
66
|
+
slug: string
|
|
67
|
+
/** merge: the absorbed slugs. */
|
|
68
|
+
merged?: string[]
|
|
69
|
+
reason?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ConsolidateResult {
|
|
73
|
+
ok: boolean
|
|
74
|
+
reason?: 'no-model' | 'no-clusters' | 'in-flight' | 'model-error' | 'invalid-output'
|
|
75
|
+
merged: string[]
|
|
76
|
+
promoted: string[]
|
|
77
|
+
deprecated: string[]
|
|
78
|
+
refreshed: string[]
|
|
79
|
+
/** Ops rejected by sanitization, with the model's raw op count for context. */
|
|
80
|
+
droppedOps?: number
|
|
81
|
+
rawOps?: number
|
|
82
|
+
calls?: number
|
|
83
|
+
actions?: ConsolidateAction[]
|
|
84
|
+
detail?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const CONSOLIDATE_SYSTEM_PROMPT = [
|
|
88
|
+
'你是 topic 记忆库的整理引擎(园丁)。输入是一个候选簇:若干条可能相近的已有 topic(JSON,含 slug/标题/状态/标签/结论摘要)。',
|
|
89
|
+
'任务:判断簇内有没有值得整理的动作,输出严格 JSON:{"ops":[...]},不要任何其他文字或代码块解释。只允许四类 op:',
|
|
90
|
+
' {"op":"merge","survivor":"保留条目的slug","merged":["被并入的slug",...],"conclusion":"合并后的完整结论","title":"合并后的标题(可选)","tags":["并集标签(可选)"],"reason":"一句话理由"}',
|
|
91
|
+
' {"op":"promote","slug":"...","reason":"一句话理由"} # draft 且结论已自含、有效 → stable',
|
|
92
|
+
' {"op":"deprecate","slug":"...","reason":"一句话理由"} # 已被更新结论取代/不再成立 → deprecated',
|
|
93
|
+
' {"op":"refresh","slug":"...","title":"...","description":"...","tags":[...],"triggers":[...],"reason":"一句话理由"} # 只修元数据,让标题/标签/触发词更准确、可检索',
|
|
94
|
+
'规则:',
|
|
95
|
+
'- merge 只用于结论重复或高度重叠的条目:conclusion 必须是合并后的完整结论(自含、不依赖原文也能读懂),取双方有效信息的并集,不丢关键事实;survivor 选信息更全或更新的那条。',
|
|
96
|
+
'- refresh 禁止改结论——它只能修 title/description/tags/triggers;想改结论就用 merge 或不要动。',
|
|
97
|
+
'- slug 必须从输入里逐字复制,禁止改写、缩写或编造;survivor 不得出现在它自己的 merged 里。',
|
|
98
|
+
'- payload 里的 injections30d/opens30d 是该 topic 近 30 天被注入命中的次数与被点开的次数:0 表示近期从未被检索命中,可作为 deprecate 或 refresh(修命名/标签)的支持证据;但不要仅凭零使用就 deprecate——新条目和小众但关键的条目也会零命中。',
|
|
99
|
+
'- 拿不准就不动:宁可输出 {"ops":[]}。禁止 create 新 topic,禁止输出这四类之外的 op。',
|
|
100
|
+
'- 全部用中文写内容;tags 全小写。',
|
|
101
|
+
].join('\n')
|
|
102
|
+
|
|
103
|
+
// --- Local clustering (lexical, no model) ------------------------------------
|
|
104
|
+
|
|
105
|
+
/** Latin words + CJK bigrams — the token space title/tags similarity lives in. */
|
|
106
|
+
export function topicTokens(text: string): Set<string> {
|
|
107
|
+
const tokens = new Set<string>()
|
|
108
|
+
const lower = text.toLowerCase()
|
|
109
|
+
for (const m of lower.matchAll(/[a-z0-9][a-z0-9._-]+/g)) tokens.add(m[0])
|
|
110
|
+
for (const m of lower.matchAll(/[\u4e00-\u9fff]+/g)) {
|
|
111
|
+
const seg = m[0]
|
|
112
|
+
if (seg.length === 1) {
|
|
113
|
+
tokens.add(seg)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
for (let i = 0; i < seg.length - 1; i += 1) tokens.add(seg.slice(i, i + 2))
|
|
117
|
+
}
|
|
118
|
+
return tokens
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
|
|
122
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
123
|
+
let inter = 0
|
|
124
|
+
for (const t of a) if (b.has(t)) inter += 1
|
|
125
|
+
return inter / (a.size + b.size - inter)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ClusterEntry {
|
|
129
|
+
slug: string
|
|
130
|
+
title: string
|
|
131
|
+
status: string
|
|
132
|
+
tags: readonly string[]
|
|
133
|
+
description?: string
|
|
134
|
+
conclusion: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface TopicCluster {
|
|
138
|
+
entries: ClusterEntry[]
|
|
139
|
+
/** Peak pairwise similarity inside the cluster — runs process peaks first. */
|
|
140
|
+
similarity: number
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Group entries whose title+description+tags tokens clear the pairwise
|
|
145
|
+
* threshold (union-find over similar pairs). Singletons are dropped — the
|
|
146
|
+
* model only ever sees topics that already look alike. Clusters larger than
|
|
147
|
+
* {@link MAX_CLUSTER_SIZE} keep their most-connected members.
|
|
148
|
+
*/
|
|
149
|
+
export function clusterTopics(
|
|
150
|
+
entries: readonly ClusterEntry[],
|
|
151
|
+
threshold = CLUSTER_THRESHOLD,
|
|
152
|
+
): TopicCluster[] {
|
|
153
|
+
const tokens = entries.map((e) => topicTokens(`${e.title} ${e.description ?? ''} ${e.tags.join(' ')}`))
|
|
154
|
+
const parent = entries.map((_, i) => i)
|
|
155
|
+
const find = (x: number): number => {
|
|
156
|
+
while (parent[x] !== x) {
|
|
157
|
+
parent[x] = parent[parent[x]]
|
|
158
|
+
x = parent[x]
|
|
159
|
+
}
|
|
160
|
+
return x
|
|
161
|
+
}
|
|
162
|
+
const scores = new Map<number, number>()
|
|
163
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
164
|
+
for (let j = i + 1; j < entries.length; j += 1) {
|
|
165
|
+
const s = jaccard(tokens[i] ?? new Set(), tokens[j] ?? new Set())
|
|
166
|
+
if (s >= threshold) {
|
|
167
|
+
parent[find(i)] = find(j)
|
|
168
|
+
scores.set(i * entries.length + j, s)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const groups = new Map<number, number[]>()
|
|
173
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
174
|
+
const root = find(i)
|
|
175
|
+
const g = groups.get(root)
|
|
176
|
+
if (g === undefined) groups.set(root, [i])
|
|
177
|
+
else g.push(i)
|
|
178
|
+
}
|
|
179
|
+
const clusters: TopicCluster[] = []
|
|
180
|
+
for (const members of groups.values()) {
|
|
181
|
+
if (members.length < 2) continue
|
|
182
|
+
let peak = 0
|
|
183
|
+
for (const [key, s] of scores) {
|
|
184
|
+
const i = Math.floor(key / entries.length)
|
|
185
|
+
if (members.includes(i)) peak = Math.max(peak, s)
|
|
186
|
+
}
|
|
187
|
+
let kept = members
|
|
188
|
+
if (members.length > MAX_CLUSTER_SIZE) {
|
|
189
|
+
// Most-connected members stay; the tail waits for a later run.
|
|
190
|
+
const degree = new Map<number, number>()
|
|
191
|
+
for (const [key, s] of scores) {
|
|
192
|
+
const i = Math.floor(key / entries.length)
|
|
193
|
+
const j = key % entries.length
|
|
194
|
+
if (!members.includes(i)) continue
|
|
195
|
+
degree.set(i, Math.max(degree.get(i) ?? 0, s))
|
|
196
|
+
degree.set(j, Math.max(degree.get(j) ?? 0, s))
|
|
197
|
+
}
|
|
198
|
+
kept = [...members]
|
|
199
|
+
.sort((a, b) => (degree.get(b) ?? 0) - (degree.get(a) ?? 0))
|
|
200
|
+
.slice(0, MAX_CLUSTER_SIZE)
|
|
201
|
+
}
|
|
202
|
+
clusters.push({
|
|
203
|
+
entries: kept.map((i) => entries[i] as ClusterEntry),
|
|
204
|
+
similarity: peak,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
clusters.sort((a, b) => b.similarity - a.similarity)
|
|
208
|
+
return clusters
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Deprecated-TTL housekeeping (local rule, no model) ----------------------
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Drop `deprecated` topics older than `ttlDays` — pure housekeeping, never
|
|
215
|
+
* touches the model. `generated.at` is the right clock: every save restamps
|
|
216
|
+
* it and nothing ever touches a deprecated entry afterwards, so it IS the
|
|
217
|
+
* "became deprecated at" timestamp. Deletion rides the normal store path
|
|
218
|
+
* (one commit per drop, index regenerated), so the history stays recoverable
|
|
219
|
+
* on the remote; an unparsable stamp is skipped, not guessed. Returns the
|
|
220
|
+
* dropped slugs. `ttlDays <= 0` disables the sweep.
|
|
221
|
+
*/
|
|
222
|
+
export async function dropExpiredDeprecated(
|
|
223
|
+
service: TopicsService,
|
|
224
|
+
ttlDays: number,
|
|
225
|
+
now = Date.now(),
|
|
226
|
+
): Promise<string[]> {
|
|
227
|
+
if (!Number.isFinite(ttlDays) || ttlDays <= 0) return []
|
|
228
|
+
const metas = await service.store.listTopics()
|
|
229
|
+
const cutoff = now - ttlDays * 86_400_000
|
|
230
|
+
const dropped: string[] = []
|
|
231
|
+
for (const m of metas) {
|
|
232
|
+
if (m.status !== 'deprecated') continue
|
|
233
|
+
const at = Date.parse(m.generatedAt)
|
|
234
|
+
if (!Number.isFinite(at) || at > cutoff) continue
|
|
235
|
+
try {
|
|
236
|
+
const removed = await service.store.deleteTopic(
|
|
237
|
+
m.slug,
|
|
238
|
+
`topics(topic): drop deprecated ${m.slug} (TTL ${ttlDays}d, deprecated ${new Date(at).toISOString().slice(0, 10)})`,
|
|
239
|
+
)
|
|
240
|
+
if (removed) dropped.push(m.slug)
|
|
241
|
+
} catch {
|
|
242
|
+
// one unreadable entry never stops the sweep
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (dropped.length > 0) {
|
|
246
|
+
service.invalidate()
|
|
247
|
+
void service.sync?.schedulePush()
|
|
248
|
+
}
|
|
249
|
+
return dropped
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- The lane ----------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
interface ClusterOutcome {
|
|
255
|
+
fatal?: 'model-error' | 'invalid-output'
|
|
256
|
+
detail?: string
|
|
257
|
+
ops: ConsolidateOp[]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface AppliedConsolidation {
|
|
261
|
+
merged: string[]
|
|
262
|
+
promoted: string[]
|
|
263
|
+
deprecated: string[]
|
|
264
|
+
refreshed: string[]
|
|
265
|
+
actions: ConsolidateAction[]
|
|
266
|
+
dropped: number
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export class Consolidator {
|
|
270
|
+
private inFlight: Promise<ConsolidateResult> | undefined
|
|
271
|
+
private readonly service: TopicsService
|
|
272
|
+
private readonly caller: ModelCaller | undefined
|
|
273
|
+
|
|
274
|
+
constructor(service: TopicsService, caller: ModelCaller | undefined) {
|
|
275
|
+
this.service = service
|
|
276
|
+
this.caller = caller
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get configured(): boolean {
|
|
280
|
+
return this.caller !== undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Cadence days for a config value; undefined = off (or unrecognized). */
|
|
284
|
+
static cadenceDays(value: string | undefined): number | undefined {
|
|
285
|
+
switch (value) {
|
|
286
|
+
case 'daily':
|
|
287
|
+
return 1
|
|
288
|
+
case '3d':
|
|
289
|
+
return 3
|
|
290
|
+
case '7d':
|
|
291
|
+
return 7
|
|
292
|
+
default:
|
|
293
|
+
return undefined
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
get hasPending(): boolean {
|
|
298
|
+
return this.inFlight !== undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Cadence-gated entry (session start): silently skips when off, not due,
|
|
303
|
+
* or already running. Due = no stamp yet (first run) or the last EVALUATED
|
|
304
|
+
* run is older than the cadence.
|
|
305
|
+
*/
|
|
306
|
+
async maybeRun(opts: { sessionId?: string; now?: number } = {}): Promise<ConsolidateResult | undefined> {
|
|
307
|
+
const days = Consolidator.cadenceDays(this.service.cfg.consolidateCadence)
|
|
308
|
+
if (days === undefined) return undefined
|
|
309
|
+
if (this.inFlight !== undefined) return undefined
|
|
310
|
+
const state = await this.service.store.readConsolidateState()
|
|
311
|
+
if (state !== undefined && typeof state.at === 'string') {
|
|
312
|
+
const last = Date.parse(state.at)
|
|
313
|
+
if (Number.isFinite(last) && (opts.now ?? Date.now()) - last < days * 86_400_000) return undefined
|
|
314
|
+
}
|
|
315
|
+
return this.start(opts.sessionId)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Manual entry (/topics consolidate): bypasses the cadence, keeps the guard. */
|
|
319
|
+
run(sessionId?: string): Promise<ConsolidateResult> {
|
|
320
|
+
return this.start(sessionId)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private start(sessionId?: string): Promise<ConsolidateResult> {
|
|
324
|
+
if (this.inFlight !== undefined) return this.inFlight
|
|
325
|
+
const run = this.runInner(sessionId).finally(() => {
|
|
326
|
+
this.inFlight = undefined
|
|
327
|
+
})
|
|
328
|
+
this.inFlight = run
|
|
329
|
+
void run.catch(() => undefined)
|
|
330
|
+
return run
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async runInner(sessionId?: string): Promise<ConsolidateResult> {
|
|
334
|
+
const base = { merged: [] as string[], promoted: [] as string[], deprecated: [] as string[], refreshed: [] as string[] }
|
|
335
|
+
if (this.caller === undefined) {
|
|
336
|
+
return { ok: false, reason: 'no-model', ...base, detail: '整理 lane 未接线(无模型调用器)' }
|
|
337
|
+
}
|
|
338
|
+
const cfg = this.service.cfg
|
|
339
|
+
if (cfg.distillProvider === '' || cfg.distillModel === '') {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
reason: 'no-model',
|
|
343
|
+
...base,
|
|
344
|
+
detail: '整理复用蒸馏模型路由(distill-provider / distill-model),当前未配置',
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const clusters = await this.buildClusters()
|
|
348
|
+
if (clusters.length === 0) {
|
|
349
|
+
// Nothing looks alike — a clean bill of health, but the model never
|
|
350
|
+
// evaluated, so the stamp stays unset and the next window re-checks.
|
|
351
|
+
return { ok: false, reason: 'no-clusters', ...base }
|
|
352
|
+
}
|
|
353
|
+
const applied: AppliedConsolidation = { merged: [], promoted: [], deprecated: [], refreshed: [], actions: [], dropped: 0 }
|
|
354
|
+
let rawOps = 0
|
|
355
|
+
let calls = 0
|
|
356
|
+
let evaluated = 0
|
|
357
|
+
let fatal: 'model-error' | 'invalid-output' | undefined
|
|
358
|
+
let failureDetail: string | undefined
|
|
359
|
+
for (const cluster of clusters) {
|
|
360
|
+
if (calls >= MAX_MODEL_CALLS_PER_RUN) break
|
|
361
|
+
const outcome = await this.runCluster(sessionId, cluster)
|
|
362
|
+
calls += 1
|
|
363
|
+
if (outcome.fatal !== undefined) {
|
|
364
|
+
fatal = outcome.fatal
|
|
365
|
+
failureDetail = outcome.detail
|
|
366
|
+
break
|
|
367
|
+
}
|
|
368
|
+
evaluated += 1
|
|
369
|
+
rawOps += outcome.ops.length
|
|
370
|
+
if (applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length >= MAX_OPS_PER_RUN) {
|
|
371
|
+
break
|
|
372
|
+
}
|
|
373
|
+
const room = MAX_OPS_PER_RUN - (applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length)
|
|
374
|
+
const result = await this.applyOps(outcome.ops.slice(0, room), cluster)
|
|
375
|
+
applied.merged.push(...result.merged)
|
|
376
|
+
applied.promoted.push(...result.promoted)
|
|
377
|
+
applied.deprecated.push(...result.deprecated)
|
|
378
|
+
applied.refreshed.push(...result.refreshed)
|
|
379
|
+
applied.actions.push(...result.actions)
|
|
380
|
+
applied.dropped += result.dropped
|
|
381
|
+
}
|
|
382
|
+
const progress = applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length > 0
|
|
383
|
+
// The stamp advances only when the model actually evaluated something —
|
|
384
|
+
// a run that died on its first call retries at the next session start.
|
|
385
|
+
let stampNote = ''
|
|
386
|
+
if (evaluated > 0) {
|
|
387
|
+
await this.service.store
|
|
388
|
+
.writeConsolidateState({
|
|
389
|
+
at: new Date().toISOString(),
|
|
390
|
+
ok: !fatal,
|
|
391
|
+
calls,
|
|
392
|
+
merged: applied.merged,
|
|
393
|
+
promoted: applied.promoted,
|
|
394
|
+
deprecated: applied.deprecated,
|
|
395
|
+
refreshed: applied.refreshed,
|
|
396
|
+
droppedOps: applied.dropped,
|
|
397
|
+
detail: failureDetail,
|
|
398
|
+
})
|
|
399
|
+
.catch((err: unknown) => {
|
|
400
|
+
stampNote = `;consolidate-state 写入失败: ${err instanceof Error ? err.message : String(err)}`
|
|
401
|
+
})
|
|
402
|
+
} else if (fatal !== undefined) {
|
|
403
|
+
return { ok: false, reason: fatal, ...base, calls, detail: failureDetail }
|
|
404
|
+
}
|
|
405
|
+
const parts: string[] = []
|
|
406
|
+
if (applied.merged.length > 0) parts.push(`合并 ${applied.merged.length} 组`)
|
|
407
|
+
if (applied.promoted.length > 0) parts.push(`晋升 ${applied.promoted.length}`)
|
|
408
|
+
if (applied.deprecated.length > 0) parts.push(`废弃 ${applied.deprecated.length}`)
|
|
409
|
+
if (applied.refreshed.length > 0) parts.push(`刷新 ${applied.refreshed.length}`)
|
|
410
|
+
const summary = parts.length === 0 ? '无需整理' : parts.join(';')
|
|
411
|
+
const tail = [
|
|
412
|
+
applied.dropped > 0 ? `丢弃 ${applied.dropped} 个无效 op` : undefined,
|
|
413
|
+
`${calls} 次模型调用(评估 ${evaluated} 簇 / 候选 ${clusters.length} 簇)`,
|
|
414
|
+
fatal !== undefined ? `中途失败:${failureDetail ?? 'unknown'}` : undefined,
|
|
415
|
+
stampNote === '' ? undefined : stampNote.replace(/^;/, ''),
|
|
416
|
+
]
|
|
417
|
+
.filter(Boolean)
|
|
418
|
+
.join(';')
|
|
419
|
+
void this.service.sync?.schedulePush()
|
|
420
|
+
return {
|
|
421
|
+
ok: evaluated > 0,
|
|
422
|
+
...(fatal !== undefined && evaluated === 0 ? { reason: fatal } : {}),
|
|
423
|
+
merged: [...applied.merged],
|
|
424
|
+
promoted: [...applied.promoted],
|
|
425
|
+
deprecated: [...applied.deprecated],
|
|
426
|
+
refreshed: [...applied.refreshed],
|
|
427
|
+
droppedOps: applied.dropped,
|
|
428
|
+
rawOps,
|
|
429
|
+
calls,
|
|
430
|
+
actions: applied.actions,
|
|
431
|
+
detail: `${summary}${tail === '' ? '' : `(${tail})`}`,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Read the active pool and cluster it lexically. */
|
|
436
|
+
private async buildClusters(): Promise<TopicCluster[]> {
|
|
437
|
+
const metas = await this.service.store.listTopics()
|
|
438
|
+
const usage = this.service.usageSignalsSync()
|
|
439
|
+
const entries: ClusterEntry[] = []
|
|
440
|
+
for (const m of metas) {
|
|
441
|
+
if (m.status === 'deprecated') continue
|
|
442
|
+
const doc = await this.service.store.readTopic(m.slug).catch(() => undefined)
|
|
443
|
+
if (doc === undefined) continue
|
|
444
|
+
entries.push({
|
|
445
|
+
slug: m.slug,
|
|
446
|
+
title: m.title,
|
|
447
|
+
status: m.status,
|
|
448
|
+
tags: m.tags,
|
|
449
|
+
description: doc.fm.description,
|
|
450
|
+
conclusion: (okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? '')
|
|
451
|
+
.slice(0, CONCLUSION_SNIPPET_CHARS)
|
|
452
|
+
.trim(),
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
return clusterTopics(entries).slice(0, MAX_MODEL_CALLS_PER_RUN)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** One model call over one cluster, ops shape-validated but not yet applied. */
|
|
459
|
+
private async runCluster(sessionId: string | undefined, cluster: TopicCluster): Promise<ClusterOutcome> {
|
|
460
|
+
const caller = this.caller
|
|
461
|
+
if (caller === undefined) return { ops: [], fatal: 'model-error', detail: 'consolidate caller unavailable' }
|
|
462
|
+
const usage = this.service.usageSignalsSync()
|
|
463
|
+
// The aggregate map only keys slugs WITH signals — but "0 hits in 30d" is
|
|
464
|
+
// exactly the gardening evidence the guardrail sentence talks about. When
|
|
465
|
+
// the logs are non-empty, absence from the map IS a measured zero; when
|
|
466
|
+
// they're empty (fresh bundle) the fields stay absent rather than lying.
|
|
467
|
+
const usageKnown = usage.size > 0
|
|
468
|
+
const usageOf = (slug: string): { injections30d: number; opens30d: number } | undefined => {
|
|
469
|
+
if (!usageKnown) return undefined
|
|
470
|
+
const u = usage.get(slug)
|
|
471
|
+
return { injections30d: u?.hits ?? 0, opens30d: u?.opens ?? 0 }
|
|
472
|
+
}
|
|
473
|
+
const payload = cluster.entries.map((e) => {
|
|
474
|
+
const base: Record<string, unknown> = {
|
|
475
|
+
slug: e.slug,
|
|
476
|
+
title: e.title,
|
|
477
|
+
status: e.status,
|
|
478
|
+
tags: e.tags,
|
|
479
|
+
conclusion: e.conclusion,
|
|
480
|
+
}
|
|
481
|
+
const u = usageOf(e.slug)
|
|
482
|
+
if (u !== undefined) Object.assign(base, u)
|
|
483
|
+
return base
|
|
484
|
+
})
|
|
485
|
+
const user = [
|
|
486
|
+
`候选簇(${payload.length} 条可能相近的 topic):`,
|
|
487
|
+
JSON.stringify(payload),
|
|
488
|
+
'',
|
|
489
|
+
'请输出整理结果(严格 JSON,{"ops":[...]});没有值得整理的就输出 {"ops":[]}:',
|
|
490
|
+
].join('\n')
|
|
491
|
+
let raw: string
|
|
492
|
+
try {
|
|
493
|
+
raw = await caller({
|
|
494
|
+
system: CONSOLIDATE_SYSTEM_PROMPT,
|
|
495
|
+
user,
|
|
496
|
+
purpose: 'topics-consolidate',
|
|
497
|
+
sessionId,
|
|
498
|
+
maxTokens: CONSOLIDATE_MAX_TOKENS,
|
|
499
|
+
})
|
|
500
|
+
} catch (e) {
|
|
501
|
+
return { ops: [], fatal: 'model-error', detail: String(e instanceof Error ? e.message : e).slice(0, 200) }
|
|
502
|
+
}
|
|
503
|
+
let ops: ConsolidateOp[]
|
|
504
|
+
try {
|
|
505
|
+
// parseOps extracts the first balanced {"ops":[...]} and filters
|
|
506
|
+
// non-object entries; the per-op shape re-validation happens in
|
|
507
|
+
// sanitizeOps (a consolidate op carries different fields than a
|
|
508
|
+
// distill op, so nothing here trusts the cast).
|
|
509
|
+
ops = parseOps(raw) as unknown as ConsolidateOp[]
|
|
510
|
+
} catch (e) {
|
|
511
|
+
return { ops: [], fatal: 'invalid-output', detail: String(e instanceof Error ? e.message : e).slice(0, 200) }
|
|
512
|
+
}
|
|
513
|
+
return { ops }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Validate and apply ops against the live bundle. Slugs are slugify-normalized
|
|
518
|
+
* and must exist; merge requires a non-empty conclusion and forbids self-merge;
|
|
519
|
+
* refresh never receives the conclusion field (merge's exclusive path); ops on
|
|
520
|
+
* slugs already merged away earlier in this run are skipped (the deprecation
|
|
521
|
+
* already redirects readers to the survivor). One bad op never sinks the rest.
|
|
522
|
+
*/
|
|
523
|
+
private async applyOps(ops: readonly ConsolidateOp[], cluster: TopicCluster): Promise<AppliedConsolidation> {
|
|
524
|
+
const applied: AppliedConsolidation = { merged: [], promoted: [], deprecated: [], refreshed: [], actions: [], dropped: 0 }
|
|
525
|
+
const clusterSlugs = new Set(cluster.entries.map((e) => e.slug))
|
|
526
|
+
for (const op of ops) {
|
|
527
|
+
try {
|
|
528
|
+
if (applied.merged.length + applied.promoted.length + applied.deprecated.length + applied.refreshed.length >= MAX_OPS_PER_RUN) break
|
|
529
|
+
if (typeof op !== 'object' || op === null) {
|
|
530
|
+
applied.dropped += 1
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
if (op.op === 'merge') {
|
|
534
|
+
const survivor = typeof op.survivor === 'string' ? okf.slugify(op.survivor) : undefined
|
|
535
|
+
const mergedRaw = Array.isArray(op.merged) ? op.merged : []
|
|
536
|
+
const merged = mergedRaw.filter((s): s is string => typeof s === 'string').map((s) => okf.slugify(s))
|
|
537
|
+
if (
|
|
538
|
+
survivor === undefined ||
|
|
539
|
+
survivor === '' ||
|
|
540
|
+
merged.length === 0 ||
|
|
541
|
+
merged.includes(survivor) ||
|
|
542
|
+
!clusterSlugs.has(survivor) ||
|
|
543
|
+
!merged.every((s) => clusterSlugs.has(s)) ||
|
|
544
|
+
typeof op.conclusion !== 'string' ||
|
|
545
|
+
op.conclusion.trim() === ''
|
|
546
|
+
) {
|
|
547
|
+
applied.dropped += 1
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
const survivorDoc = await this.service.store.readTopic(survivor)
|
|
551
|
+
if (survivorDoc === undefined) {
|
|
552
|
+
applied.dropped += 1
|
|
553
|
+
continue
|
|
554
|
+
}
|
|
555
|
+
await this.service.saveTopic({
|
|
556
|
+
slug: survivor,
|
|
557
|
+
title: typeof op.title === 'string' && op.title.trim() !== '' ? op.title : survivorDoc.fm.title,
|
|
558
|
+
description: op.description,
|
|
559
|
+
tags: op.tags,
|
|
560
|
+
conclusion: op.conclusion,
|
|
561
|
+
})
|
|
562
|
+
for (const slug of merged) {
|
|
563
|
+
const doc = await this.service.store.readTopic(slug)
|
|
564
|
+
if (doc === undefined) continue
|
|
565
|
+
const original = okf.sectionOf(doc.body, okf.CONCLUSION_HEADING) ?? ''
|
|
566
|
+
const pointer = `> 已并入 topics/${survivor}.md(consolidation ${new Date().toISOString().slice(0, 10)}${op.reason ? `:${op.reason}` : ''})。以下结论保留作历史参考。`
|
|
567
|
+
await this.service.saveTopic({
|
|
568
|
+
slug,
|
|
569
|
+
title: doc.fm.title,
|
|
570
|
+
conclusion: `${pointer}\n\n${original}`.trim(),
|
|
571
|
+
status: 'deprecated',
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
applied.merged.push(survivor)
|
|
575
|
+
applied.actions.push({ kind: 'merge', slug: survivor, merged, reason: op.reason })
|
|
576
|
+
} else if (op.op === 'promote' || op.op === 'deprecate') {
|
|
577
|
+
const slug = typeof op.slug === 'string' ? okf.slugify(op.slug) : undefined
|
|
578
|
+
if (slug === undefined || slug === '' || !clusterSlugs.has(slug)) {
|
|
579
|
+
applied.dropped += 1
|
|
580
|
+
continue
|
|
581
|
+
}
|
|
582
|
+
const doc = await this.service.store.readTopic(slug)
|
|
583
|
+
if (doc === undefined || doc.fm.status === (op.op === 'promote' ? 'stable' : 'deprecated')) {
|
|
584
|
+
applied.dropped += 1
|
|
585
|
+
continue
|
|
586
|
+
}
|
|
587
|
+
await this.service.saveTopic({
|
|
588
|
+
slug,
|
|
589
|
+
title: doc.fm.title,
|
|
590
|
+
status: op.op === 'promote' ? 'stable' : 'deprecated',
|
|
591
|
+
})
|
|
592
|
+
if (op.op === 'promote') applied.promoted.push(slug)
|
|
593
|
+
else applied.deprecated.push(slug)
|
|
594
|
+
applied.actions.push({ kind: op.op, slug, reason: op.reason })
|
|
595
|
+
} else if (op.op === 'refresh') {
|
|
596
|
+
const slug = typeof op.slug === 'string' ? okf.slugify(op.slug) : undefined
|
|
597
|
+
if (slug === undefined || slug === '' || !clusterSlugs.has(slug)) {
|
|
598
|
+
applied.dropped += 1
|
|
599
|
+
continue
|
|
600
|
+
}
|
|
601
|
+
const doc = await this.service.store.readTopic(slug)
|
|
602
|
+
if (doc === undefined) {
|
|
603
|
+
applied.dropped += 1
|
|
604
|
+
continue
|
|
605
|
+
}
|
|
606
|
+
// Conclusion is merge's exclusive path — a refresh op carrying one
|
|
607
|
+
// is dropped rather than silently rewritten.
|
|
608
|
+
if (op.conclusion !== undefined) {
|
|
609
|
+
applied.dropped += 1
|
|
610
|
+
continue
|
|
611
|
+
}
|
|
612
|
+
const hasField =
|
|
613
|
+
(typeof op.title === 'string' && op.title.trim() !== '') ||
|
|
614
|
+
(typeof op.description === 'string' && op.description.trim() !== '') ||
|
|
615
|
+
(Array.isArray(op.tags) && op.tags.length > 0) ||
|
|
616
|
+
(Array.isArray(op.triggers) && op.triggers.length > 0)
|
|
617
|
+
if (!hasField) {
|
|
618
|
+
applied.dropped += 1
|
|
619
|
+
continue
|
|
620
|
+
}
|
|
621
|
+
await this.service.saveTopic({
|
|
622
|
+
slug,
|
|
623
|
+
title: typeof op.title === 'string' && op.title.trim() !== '' ? op.title : doc.fm.title,
|
|
624
|
+
description: op.description,
|
|
625
|
+
tags: op.tags,
|
|
626
|
+
triggers: op.triggers,
|
|
627
|
+
})
|
|
628
|
+
applied.refreshed.push(slug)
|
|
629
|
+
applied.actions.push({ kind: 'refresh', slug, reason: op.reason })
|
|
630
|
+
} else {
|
|
631
|
+
applied.dropped += 1
|
|
632
|
+
}
|
|
633
|
+
} catch {
|
|
634
|
+
applied.dropped += 1
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return applied
|
|
638
|
+
}
|
|
639
|
+
}
|
package/src/ilog.ts
CHANGED
|
@@ -138,3 +138,56 @@ export function querySample(query: string): string {
|
|
|
138
138
|
const words = query.replace(/\s+/g, ' ').trim()
|
|
139
139
|
return words.length <= 40 ? words : `${words.slice(0, 40)}…`
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/** Rolling usage-signal window (ADR 0015) — days of Injection/Open history. */
|
|
143
|
+
export const USAGE_WINDOW_DAYS = 30
|
|
144
|
+
|
|
145
|
+
export interface UsageSignal {
|
|
146
|
+
hits: number
|
|
147
|
+
opens: number
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Usage signals per slug over a rolling window (ADR 0015): an injection hit
|
|
152
|
+
* (truly assembled, not deduped/echoed/dropped) counts 1, a topic_open counts
|
|
153
|
+
* 3 — the model chose to read the full text, the strongest "this helped"
|
|
154
|
+
* evidence available. Downstream, only PRESENCE matters (the boost is a
|
|
155
|
+
* fixed capped bonus, never linear in votes); the counts exist for the
|
|
156
|
+
* consolidation payload, where "0 vs many" is the gardening evidence.
|
|
157
|
+
*/
|
|
158
|
+
export function aggregateUsage(
|
|
159
|
+
injections: readonly InjectionRecord[],
|
|
160
|
+
opens: readonly { slug: string; at: string }[],
|
|
161
|
+
windowDays: number,
|
|
162
|
+
now: number,
|
|
163
|
+
): Map<string, UsageSignal> {
|
|
164
|
+
const cutoff = now - windowDays * 86_400_000
|
|
165
|
+
const out = new Map<string, UsageSignal>()
|
|
166
|
+
const bump = (slug: string, kind: keyof UsageSignal): void => {
|
|
167
|
+
if (slug === '') return
|
|
168
|
+
const entry = out.get(slug) ?? { hits: 0, opens: 0 }
|
|
169
|
+
entry[kind] += 1
|
|
170
|
+
out.set(slug, entry)
|
|
171
|
+
}
|
|
172
|
+
for (const record of injections) {
|
|
173
|
+
const at = Date.parse(record.at)
|
|
174
|
+
if (!Number.isFinite(at) || at < cutoff) continue
|
|
175
|
+
if (record.injected !== true) continue
|
|
176
|
+
const excluded = new Set<string>([
|
|
177
|
+
...(record.deduped ?? []),
|
|
178
|
+
...(record.echoed ?? []),
|
|
179
|
+
...(record.dropped ?? []).map((d) => d.slug),
|
|
180
|
+
])
|
|
181
|
+
for (const hit of record.hits) {
|
|
182
|
+
if (excluded.has(hit.slug)) continue
|
|
183
|
+
bump(hit.slug, 'hits')
|
|
184
|
+
}
|
|
185
|
+
for (const slow of record.slow ?? []) bump(slow.slug, 'hits')
|
|
186
|
+
}
|
|
187
|
+
for (const open of opens) {
|
|
188
|
+
const at = Date.parse(open.at)
|
|
189
|
+
if (!Number.isFinite(at) || at < cutoff) continue
|
|
190
|
+
bump(open.slug, 'opens')
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|