@aiwayds/dsh-topics-memory 0.16.1-next.1 → 0.18.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 +4 -4
- package/README.zh.md +3 -3
- package/icon.svg +6 -0
- package/lib/commands.js +29 -0
- package/lib/config.d.ts +55 -55
- package/lib/config.js +26 -26
- package/lib/distill.d.ts +8 -0
- package/lib/distill.js +1 -1
- package/lib/index.d.ts +131 -32
- package/lib/index.js +295 -162
- package/lib/store.d.ts +13 -0
- package/lib/store.js +25 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +30 -27
- package/skills/dsh-topics-memory-config/SKILL.md +6 -6
- package/src/commands.ts +26 -0
- package/src/config.ts +26 -26
- package/src/distill.ts +15 -1
- package/src/index.ts +376 -182
- package/src/store.ts +25 -0
package/src/index.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* dsh-topics-memory — OKF topic memory for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
4
|
* Wiring (per ADR 0001–0008):
|
|
5
|
-
* - `
|
|
5
|
+
* - settings page = projection of the plugin `Config` schema, namespace =
|
|
6
|
+
* profile entry id `dsh-topics-memory` (user-tunable via /topics set)
|
|
6
7
|
* - static systemPrompt section teaching the topic tools (never volatile
|
|
7
8
|
* content — the provider cache prefix stays byte-stable)
|
|
8
9
|
* - same-turn injection: retrieval runs synchronously at inbox-claim time
|
|
@@ -86,95 +87,199 @@ export function settleBounded(p: Promise<unknown> | undefined, ms: number): Prom
|
|
|
86
87
|
* skills registry serves the bundled usage/config guide. */
|
|
87
88
|
export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills']
|
|
88
89
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
90
|
+
// ---- Fast-lane claim-text rebuild from the session log (0.17.0/0.17.1) ----
|
|
91
|
+
//
|
|
92
|
+
// dsh 0.1.5-rc.2's SessionProjectionRegistry registers its eager inbox drive
|
|
93
|
+
// at session/event hooksOrder[0]; every later handler — this plugin sits at
|
|
94
|
+
// [10] — observes the projection AFTER the current splice was applied, so the
|
|
95
|
+
// pre-0.1.5 contract "live dispatch precedes projection mutation" is gone and
|
|
96
|
+
// the projection read at `agent/inbox/spliced` time yields the post-splice
|
|
97
|
+
// window. 0.17.0 armed the replay only when that read came back empty —
|
|
98
|
+
// which still mis-fired with ≥2 pending messages (review CONCERN A): the
|
|
99
|
+
// post-splice projection is NON-empty but shifted by one, so a claim of msg1
|
|
100
|
+
// read msg2's residual text and the replay never armed. Since 0.17.1 the
|
|
101
|
+
// replay is the UNCONDITIONAL first source whenever event.seq exists:
|
|
102
|
+
// dsh-agent-loop's InboxProjector funnels every append/prepend/replace/
|
|
103
|
+
// remove/claim/cancel through mutate(), and mutate() appends each normalized
|
|
104
|
+
// splice — target, clamped coordinates, AND the full inserted messages — to
|
|
105
|
+
// the session log as `agent/inbox/spliced`. Folding that log prefix up to
|
|
106
|
+
// (excluding) the current event's seq reproduces the exact pre-splice pending
|
|
107
|
+
// window — the very coordinates the claim is defined against — with zero
|
|
108
|
+
// dependence on dispatch order. The projection read survives only as the
|
|
109
|
+
// fallback for hosts whose events carry no seq (or whose session object
|
|
110
|
+
// exposes no snapshotEvents).
|
|
95
111
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
/** A firehose event as the replay path reads it: envelope fields only. */
|
|
113
|
+
export interface InboxEventLike {
|
|
114
|
+
seq?: unknown
|
|
115
|
+
type?: unknown
|
|
116
|
+
data?: unknown
|
|
117
|
+
}
|
|
100
118
|
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
/** The pending-inbox projection replayed from a session-log prefix. */
|
|
120
|
+
export interface PendingInboxWindow {
|
|
121
|
+
'next-turn': readonly unknown[]
|
|
122
|
+
'next-step': readonly unknown[]
|
|
103
123
|
}
|
|
104
124
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
/** One `agent/inbox/spliced` log entry (shape per dsh-agent-loop mutate()). */
|
|
126
|
+
interface InboxSpliceData {
|
|
127
|
+
target?: string
|
|
128
|
+
start: number
|
|
129
|
+
removedCount?: number
|
|
130
|
+
inserted?: readonly unknown[]
|
|
108
131
|
}
|
|
109
132
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Fold a session-log prefix into the pending-inbox projection state.
|
|
135
|
+
* Coordinates in the log are already normalize-at-append (dsh-agent-loop
|
|
136
|
+
* clamps start/deleteCount before appending), so each entry applies as a
|
|
137
|
+
* plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
|
|
138
|
+
* the current splice's own seq to fold everything BEFORE it. Returns
|
|
139
|
+
* undefined when any entry is malformed enough to break the fold (the
|
|
140
|
+
* caller degrades to the projection read, as before).
|
|
141
|
+
*/
|
|
142
|
+
export function replayPendingInbox(events: readonly InboxEventLike[], upToSeqExclusive: number): PendingInboxWindow | undefined {
|
|
143
|
+
const window: { 'next-turn': unknown[]; 'next-step': unknown[] } = { 'next-turn': [], 'next-step': [] }
|
|
144
|
+
try {
|
|
145
|
+
for (const event of events) {
|
|
146
|
+
const seq = event.seq
|
|
147
|
+
if (typeof seq === 'number' && seq >= upToSeqExclusive) break
|
|
148
|
+
if (event.type !== 'agent/inbox/spliced') continue
|
|
149
|
+
const splice = event.data as InboxSpliceData | null | undefined
|
|
150
|
+
if (splice === null || typeof splice !== 'object') continue
|
|
151
|
+
const target = splice.target ?? 'next-turn'
|
|
152
|
+
if (target !== 'next-turn' && target !== 'next-step') continue
|
|
153
|
+
const start = Math.trunc(splice.start)
|
|
154
|
+
const removedCount = Math.trunc(splice.removedCount ?? 0)
|
|
155
|
+
if (!Number.isFinite(start) || !Number.isFinite(removedCount) || start < 0 || removedCount < 0) continue
|
|
156
|
+
window[target] = window[target].toSpliced(start, removedCount, ...(splice.inserted ?? []))
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
return window
|
|
113
162
|
}
|
|
114
163
|
|
|
115
164
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* `mutate` simply skips the migration.
|
|
165
|
+
* Claimed-text extraction, shared verbatim by the projection read and the
|
|
166
|
+
* log replay: user-kind messages only, text blocks joined per message —
|
|
167
|
+
* the pre-0.17.0 splice-window semantics.
|
|
120
168
|
*/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
169
|
+
export function claimedUserText(messages: readonly unknown[]): string {
|
|
170
|
+
let claimedText = ''
|
|
171
|
+
for (const message of messages) {
|
|
172
|
+
const data = message as UserMessageData | null | undefined
|
|
173
|
+
if (data === null || typeof data !== 'object' || data.source?.kind !== 'user') continue
|
|
174
|
+
const text = textOf(data as UserMessageLike)
|
|
175
|
+
if (text.trim() !== '') claimedText = claimedText === '' ? text : `${claimedText}\n${text}`
|
|
176
|
+
}
|
|
177
|
+
return claimedText
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Where the resolved claimed text actually came from (see resolveClaimedText). */
|
|
181
|
+
export type ClaimedTextSource = 'log-replay' | 'projection'
|
|
182
|
+
|
|
183
|
+
export interface ClaimedTextResolution {
|
|
184
|
+
text: string
|
|
185
|
+
source: ClaimedTextSource
|
|
125
186
|
}
|
|
126
187
|
|
|
127
188
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
189
|
+
* Resolve the claimed text for one splice, ordering the two sources by
|
|
190
|
+
* authority (review CONCERN A, 0.17.1):
|
|
191
|
+
*
|
|
192
|
+
* 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
|
|
193
|
+
* carries a seq and a log prefix is available. The fold reproduces the
|
|
194
|
+
* pre-splice window the claim coordinates (start/removedCount) are
|
|
195
|
+
* defined against, so it is immune to handler order AND to the backlog
|
|
196
|
+
* misalignment: with ≥2 pending messages the post-splice projection
|
|
197
|
+
* read is NON-empty but shifted by one (a claim of msg1 would read
|
|
198
|
+
* msg2's residual text), which the 0.17.0 empty-projection-only gating
|
|
199
|
+
* missed. The replay verdict is FINAL — when the fold succeeds the
|
|
200
|
+
* projection is not consulted, even if the replayed slice comes back
|
|
201
|
+
* empty (the pre-splice window genuinely held no user text).
|
|
202
|
+
* 2. Projection read — the fallback for hosts where the replay cannot arm
|
|
203
|
+
* (no event.seq on the firehose, or snapshotEvents unreachable/failed).
|
|
204
|
+
* Pre-0.1.5 live dispatch precedes the mutation, so this read yields
|
|
205
|
+
* the pre-splice window, as it always did.
|
|
206
|
+
*
|
|
207
|
+
* `source` names the source that ACTUALLY produced the returned text
|
|
208
|
+
* (review CONCERN B): 'log-replay' whenever the replay supplied the
|
|
209
|
+
* verdict (empty included), 'projection' only when the fallback did (or
|
|
210
|
+
* the replay never armed).
|
|
141
211
|
*/
|
|
142
|
-
export function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
'settings migration was skipped because the "llmwiki" namespace is already registered. ' +
|
|
161
|
-
'Remove @aiwayds/dsh-llmwiki-memory from this profile so only dsh-topics-memory loads.',
|
|
162
|
-
)
|
|
163
|
-
return undefined
|
|
212
|
+
export function resolveClaimedText(input: {
|
|
213
|
+
/** The pending window as read at handler time (post-splice on 0.1.5+). */
|
|
214
|
+
projection: readonly unknown[]
|
|
215
|
+
/** Session-log prefix up to (excluding) `seq`, or undefined when unreachable. */
|
|
216
|
+
logEvents: readonly InboxEventLike[] | undefined
|
|
217
|
+
/** The splice event's own seq — a number marks a replay-capable host. */
|
|
218
|
+
seq: unknown
|
|
219
|
+
target: 'next-turn' | 'next-step'
|
|
220
|
+
start: number
|
|
221
|
+
removedCount: number
|
|
222
|
+
}): ClaimedTextResolution {
|
|
223
|
+
if (typeof input.seq === 'number' && input.logEvents !== undefined) {
|
|
224
|
+
const window = replayPendingInbox(input.logEvents, input.seq)
|
|
225
|
+
if (window !== undefined) {
|
|
226
|
+
return {
|
|
227
|
+
text: claimedUserText(window[input.target].slice(input.start, input.start + input.removedCount)),
|
|
228
|
+
source: 'log-replay',
|
|
229
|
+
}
|
|
164
230
|
}
|
|
165
|
-
// Registering the legacy namespace is the only API that surfaces its
|
|
166
|
-
// stored section; a duplicate or schema-invalid section throws — contained.
|
|
167
|
-
settings.register(LEGACY_NS, TopicsConfig)
|
|
168
|
-
const legacyUser = findUser(LEGACY_NS)
|
|
169
|
-
if (legacyUser === undefined) return undefined
|
|
170
|
-
const ops = CONFIG_KEYS.filter((k) => k in legacyUser && legacyUser[k] !== DEFAULTS[k]).map(
|
|
171
|
-
(k) => ({ op: 'set' as const, path: [k], value: legacyUser[k] }),
|
|
172
|
-
)
|
|
173
|
-
if (ops.length === 0) return undefined
|
|
174
|
-
return settings.mutate?.call(settings, OWN_NS, ops)?.catch(() => undefined)
|
|
175
|
-
} catch {
|
|
176
|
-
return undefined
|
|
177
231
|
}
|
|
232
|
+
return {
|
|
233
|
+
text: claimedUserText(input.projection.slice(input.start, input.start + input.removedCount)),
|
|
234
|
+
source: 'projection',
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// dsh 0.1.7 settings: the runtime namespace registry (register/describe per
|
|
239
|
+
// plugin) is gone. A plugin's settings page is the projection of its `Config`
|
|
240
|
+
// schema below, and the settings namespace is the profile entry id
|
|
241
|
+
// (`dsh-topics-memory`, cordis.patch.yml) — the same spelling /topics set
|
|
242
|
+
// passes to ctx.settings.mutate(). A legacy top-level `topics:` section in
|
|
243
|
+
// settings.yaml is NOT auto-imported under this id — the host imports the
|
|
244
|
+
// old settings.yaml once, then renames it to settings.yaml.imported.
|
|
245
|
+
export const Config = TopicsConfig
|
|
246
|
+
|
|
247
|
+
/** Settings namespace this plugin reads and writes: the profile entry id. */
|
|
248
|
+
const ENTRY_ID = 'dsh-topics-memory'
|
|
249
|
+
|
|
250
|
+
/** Live config reference as a 0.1.7 host passes it (cron / mcp-adapter
|
|
251
|
+
* spelling): `.get()` snapshots the current value — the host swaps the
|
|
252
|
+
* reference in-place when a volatile field is edited on the settings page. */
|
|
253
|
+
interface VolatileRef<T> {
|
|
254
|
+
get(): T
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** apply()-time config: live VolatileRef fields on real hosts, plain values
|
|
258
|
+
* on bare test harnesses — {@link readConfigValue} accepts both. */
|
|
259
|
+
export type TopicsRuntimeConfig = Partial<{ [K in keyof TopicsConfigValue]: VolatileRef<TopicsConfigValue[K]> | TopicsConfigValue[K] }>
|
|
260
|
+
|
|
261
|
+
/** Snapshot one config slot: live ref → current value, plain value → itself. */
|
|
262
|
+
function readConfigValue(slot: unknown): unknown {
|
|
263
|
+
const ref = slot as { get?: unknown } | undefined
|
|
264
|
+
return typeof ref?.get === 'function' ? (ref as { get(): unknown }).get() : slot
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface AgentMapLike {
|
|
268
|
+
get(id: unknown): { inbox: { nextTurn: readonly unknown[]; nextStep: readonly unknown[] } } | undefined
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface SessionEvent {
|
|
272
|
+
type: string
|
|
273
|
+
data: unknown
|
|
274
|
+
/** Log sequence number, present on hosts whose firehose carries appended
|
|
275
|
+
* events (dsh 0.1.5+). The fast-lane log replay keys on it; absent on
|
|
276
|
+
* older hosts, where the replay simply never arms. */
|
|
277
|
+
seq?: number
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface UserMessageData {
|
|
281
|
+
source?: { kind?: string }
|
|
282
|
+
content: readonly { type: string; text?: string }[]
|
|
178
283
|
}
|
|
179
284
|
|
|
180
285
|
// --- Bundled skill -----------------------------------------------------------
|
|
@@ -194,7 +299,7 @@ const SKILL_RESOURCE_BASE = {
|
|
|
194
299
|
const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true } as const
|
|
195
300
|
|
|
196
301
|
/** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
|
|
197
|
-
const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏/整理,或要配置 topics
|
|
302
|
+
const SKILL_DESCRIPTION = 'dsh 记忆插件(@aiwayds/dsh-topics-memory)使用与配置指南。凡涉及 dsh 记忆/话题库/GitHub 同步/蒸馏/整理,或要配置 topics 时先读本指南:设置页 `dsh-topics-memory` 条目全部键(repo/autoInject/topK/注入预算/蒸馏/整理/观察/图游走等)、/topics 命令族(onboard/status/distill/consolidate/stats/list/show/history/graph/sync/config/set)、首次配置 ask_user_question 向导(local-only 或绑 GitHub 仓、蒸馏模型路由、注入档位、自动观察)、注入形态 pointer/digest。触发词:topics、记忆、topic、蒸馏、distill、整理、consolidate、合并重复、deprecatedTtl、usageBoost、使用加成、autoInject、记忆库、include-subagents。'
|
|
198
303
|
|
|
199
304
|
const SKILL_CANDIDATE: SkillCandidate = {
|
|
200
305
|
name: SKILL_PROVIDER_NAME,
|
|
@@ -249,18 +354,16 @@ export function stripFrontmatter(raw: string): string {
|
|
|
249
354
|
return raw
|
|
250
355
|
}
|
|
251
356
|
|
|
252
|
-
export function apply(ctx: Context): void {
|
|
357
|
+
export function apply(ctx: Context, config: TopicsRuntimeConfig = {}): void {
|
|
253
358
|
// `inject = ['skills']` guarantees the service exists on every real host;
|
|
254
359
|
// register unconditionally so a missing service fails loud instead of
|
|
255
360
|
// silently dropping the bundled skill.
|
|
256
361
|
ctx.skills.registerProvider(() => skillProvider)
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
if (settingsNs === undefined) return
|
|
261
|
-
const scope = settingsNs.register(OWN_NS, TopicsConfig)
|
|
362
|
+
// 0.1.7 hosts validate the profile patch against `Config` above and hand
|
|
363
|
+
// apply() live volatile references; bare harnesses pass plain values (or
|
|
364
|
+
// nothing) — readConfigValue treats both shapes the same.
|
|
262
365
|
// Host logger access is best-effort: cordis always provides one, but bare
|
|
263
|
-
// test harnesses may not — the
|
|
366
|
+
// test harnesses may not — the logging stays silent rather than throwing.
|
|
264
367
|
const warn = (message: string): void => {
|
|
265
368
|
try {
|
|
266
369
|
const logger = (ctx as unknown as { logger?: { warn?: (m: string) => void; error?: (m: string) => void; info?: (m: string) => void } }).logger
|
|
@@ -270,14 +373,32 @@ export function apply(ctx: Context): void {
|
|
|
270
373
|
// contained — a missing/broken logger must not break startup
|
|
271
374
|
}
|
|
272
375
|
}
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
376
|
+
// Low-noise observability for the fast lane's early exits. The 2026-09-21
|
|
377
|
+
// silent-death incident (0.1.5-rc.2 claim order) died precisely because
|
|
378
|
+
// every exit path was silent: the injected log stayed empty for weeks
|
|
379
|
+
// while the observer kept recording. First occurrence warns immediately;
|
|
380
|
+
// repeats batch into one count line per SILENT_EXIT_LOG_EVERY occurrences.
|
|
381
|
+
// DSH_TOPICS_DEBUG=1 logs every single one for bench debugging.
|
|
382
|
+
const SILENT_EXIT_LOG_EVERY = 50
|
|
383
|
+
const debugVerbose = process.env.DSH_TOPICS_DEBUG === '1'
|
|
384
|
+
const silentExitCounts = new Map<string, number>()
|
|
385
|
+
const silentExit = (key: string, detail: string): void => {
|
|
386
|
+
const n = (silentExitCounts.get(key) ?? 0) + 1
|
|
387
|
+
silentExitCounts.set(key, n)
|
|
388
|
+
if (debugVerbose || n === 1 || n % SILENT_EXIT_LOG_EVERY === 0) {
|
|
389
|
+
warn(`dsh-topics-memory 快道本轮未触发(${key},累计 ${n} 次):${detail}`)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
277
392
|
const cfgNow = (): TopicsConfigValue => {
|
|
278
|
-
const v
|
|
393
|
+
const v: Record<string, unknown> = {}
|
|
394
|
+
for (const k of CONFIG_KEYS) {
|
|
395
|
+
const slot = (config as Record<string, unknown>)[k]
|
|
396
|
+
// Skip absent keys so the DEFAULTS fold below keeps them (an explicit
|
|
397
|
+
// undefined spread would wipe the default).
|
|
398
|
+
if (slot !== undefined) v[k] = readConfigValue(slot)
|
|
399
|
+
}
|
|
279
400
|
// Schema defaults may not be applied by bare test harnesses; fill them in.
|
|
280
|
-
return { ...DEFAULTS, ...v }
|
|
401
|
+
return { ...DEFAULTS, ...v } as TopicsConfigValue
|
|
281
402
|
}
|
|
282
403
|
|
|
283
404
|
const root = paths.resolveBundleRoot()
|
|
@@ -351,6 +472,15 @@ export function apply(ctx: Context): void {
|
|
|
351
472
|
}
|
|
352
473
|
}
|
|
353
474
|
const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
|
|
475
|
+
// The service-layer early exit (empty roster — e.g. a probe home with
|
|
476
|
+
// no topics seeded) writes NO injection record and used to be fully
|
|
477
|
+
// silent (the P8 early-exit the incident audit enumerated). autoInject
|
|
478
|
+
// off never reaches here (the spliced branch gates it) and an empty
|
|
479
|
+
// query is pre-filtered by the empty-claimed exit, so rosterSize 0 on
|
|
480
|
+
// this path means exactly "roster empty".
|
|
481
|
+
if (r.outcome.rosterSize === 0) {
|
|
482
|
+
silentExit('no-roster', 'roster 为空(topics 库 0 条)——service 层早退,本轮不写 ilog 记录')
|
|
483
|
+
}
|
|
354
484
|
// Mark only what entered the context this round (hits AND slow picks →
|
|
355
485
|
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
356
486
|
// injectable).
|
|
@@ -529,9 +659,17 @@ export function apply(ctx: Context): void {
|
|
|
529
659
|
if (event.type === 'agent/inbox/spliced') {
|
|
530
660
|
if (!cfgNow().autoInject) return
|
|
531
661
|
const splice = event.data as { target?: string; start: number; removedCount?: number; outcome?: string }
|
|
532
|
-
|
|
662
|
+
// A canceled claim is routine host flow (inbox cancel/remove), not a
|
|
663
|
+
// fault signal — it only logs under DSH_TOPICS_DEBUG, never warns.
|
|
664
|
+
if (!splice.removedCount || splice.outcome === 'canceled') {
|
|
665
|
+
if (debugVerbose) silentExit('canceled', `target=${String(splice.target)} removedCount=${String(splice.removedCount)} outcome=${String(splice.outcome)}`)
|
|
666
|
+
return
|
|
667
|
+
}
|
|
533
668
|
const agent = agents()?.get(session.id)
|
|
534
|
-
if (agent === undefined)
|
|
669
|
+
if (agent === undefined) {
|
|
670
|
+
silentExit('agent-missing', `agents() 无此会话 ${sessionId}——快道文本无法定位`)
|
|
671
|
+
return
|
|
672
|
+
}
|
|
535
673
|
// The agent-scoped context carries the llm instance this agent's own
|
|
536
674
|
// loop streams through (adapters included) — capture it while alive.
|
|
537
675
|
try {
|
|
@@ -542,21 +680,46 @@ export function apply(ctx: Context): void {
|
|
|
542
680
|
} catch {
|
|
543
681
|
// scope already unwinding — the root fallback stays
|
|
544
682
|
}
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
683
|
+
const target = (splice.target ?? 'next-turn') === 'next-step' ? 'next-step' : 'next-turn'
|
|
684
|
+
const list = (target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn) as readonly UserMessageLike[]
|
|
685
|
+
// Authority — session-log replay (resolveClaimedText, CONCERN A):
|
|
686
|
+
// whenever the event carries a seq, the fold runs FIRST and its
|
|
687
|
+
// verdict is final — with ≥2 pending messages the post-splice
|
|
688
|
+
// projection read is non-empty but shifted by one, so gating the
|
|
689
|
+
// replay on an empty projection would claim msg1 and read msg2.
|
|
690
|
+
const seq = event.seq
|
|
691
|
+
let logEvents: readonly InboxEventLike[] | undefined
|
|
692
|
+
if (typeof seq === 'number') {
|
|
693
|
+
try {
|
|
694
|
+
// Soft-deprecated surface, deliberately kept (dsh 0.17 migration):
|
|
695
|
+
// `snapshotEvents` still ships in dsh-session 0.1.7-rc.1 and this is
|
|
696
|
+
// the replay's data source, reachable only behind event.seq with a
|
|
697
|
+
// full projection-read fallback below. Migrating to registered
|
|
698
|
+
// SessionMessageProjections is a dedicated project, not a drive-by.
|
|
699
|
+
const snapshot = (session as unknown as {
|
|
700
|
+
snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly InboxEventLike[]
|
|
701
|
+
}).snapshotEvents
|
|
702
|
+
if (typeof snapshot === 'function') logEvents = snapshot.call(session, 0, seq)
|
|
703
|
+
} catch {
|
|
704
|
+
// contained — the projection fallback inside resolveClaimedText
|
|
705
|
+
// stays the verdict
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const claimed = resolveClaimedText({ projection: list, logEvents, seq, target, start: splice.start, removedCount: splice.removedCount })
|
|
709
|
+
if (claimed.text.trim() === '') {
|
|
710
|
+
// claimed.source names where the (empty) verdict actually came
|
|
711
|
+
// from (CONCERN B): 'log-replay' = the fold ran and the pre-splice
|
|
712
|
+
// window genuinely held no user text; 'projection' = the replay
|
|
713
|
+
// never armed (no seq / snapshotEvents unreachable) and the
|
|
714
|
+
// projection read was empty too.
|
|
715
|
+
silentExit(`empty-claimed/${claimed.source}`, `target=${target} start=${String(splice.start)} removedCount=${String(splice.removedCount)}——被 claim 窗口重建后仍无用户文本`)
|
|
716
|
+
return
|
|
553
717
|
}
|
|
554
|
-
if (claimedText.trim() === '') return
|
|
555
718
|
const state = turns.get(sessionId) ?? { claimedText: '', injectionText: '' }
|
|
556
|
-
state.claimedText =
|
|
719
|
+
state.claimedText = claimed.text
|
|
557
720
|
state.injectionText = ''
|
|
558
721
|
turns.set(sessionId, state)
|
|
559
|
-
retrieveForTurn(sessionId,
|
|
722
|
+
retrieveForTurn(sessionId, claimed.text)
|
|
560
723
|
return
|
|
561
724
|
}
|
|
562
725
|
if (event.type === 'turn/start') {
|
|
@@ -586,11 +749,6 @@ export function apply(ctx: Context): void {
|
|
|
586
749
|
}) as never,
|
|
587
750
|
)
|
|
588
751
|
|
|
589
|
-
// Session-start boot chain dedup: rc.2's creation transaction emits BOTH
|
|
590
|
-
// `agent/session-start` and `agent/created`, so the pair collapses to one
|
|
591
|
-
// chain run per session; teardown clears the entry.
|
|
592
|
-
const bootedSessions = new Set<string>()
|
|
593
|
-
|
|
594
752
|
// ---- Session teardown: real cordis events, not session/event types ----
|
|
595
753
|
// dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
|
|
596
754
|
// `session/disposed` event type — the old `event.type === 'agent/disposed'`
|
|
@@ -614,7 +772,6 @@ export function apply(ctx: Context): void {
|
|
|
614
772
|
observer.onSessionEvent(sessionId, name, undefined)
|
|
615
773
|
injectedBySession.delete(sessionId)
|
|
616
774
|
slowLane.clear(sessionId)
|
|
617
|
-
bootedSessions.delete(sessionId)
|
|
618
775
|
// A teardown-triggered run (session-end distill) reads the payload
|
|
619
776
|
// capture lazily: drop the entry here only when no run can still read
|
|
620
777
|
// it. The slow lane's in-flight pipeline reads it too (shared caller);
|
|
@@ -623,86 +780,121 @@ export function apply(ctx: Context): void {
|
|
|
623
780
|
}) as never)
|
|
624
781
|
}
|
|
625
782
|
|
|
626
|
-
// ----
|
|
783
|
+
// ---- Boot chain: pull on session start, replay-distill, consolidate, TTL ----
|
|
627
784
|
// Session start is an agent-bus event, never a session/event firehose type:
|
|
628
|
-
// the firehose only carries Session.append types.
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
785
|
+
// the firehose only carries Session.append types. dsh 0.1.7 announces ONE
|
|
786
|
+
// serial `agent/created` event (payload { agent, source, signal? }, source
|
|
787
|
+
// ∈ 'startup' | 'resume' | 'clear' | 'compact') and `agent/session-start`
|
|
788
|
+
// is gone with no shim — 0.16.1's dual-name registration collapses to a
|
|
789
|
+
// single listener, and with it the per-session dedup Set: the registry
|
|
790
|
+
// guarantees exactly one announce per agent entry (a second announce
|
|
791
|
+
// throws), so a 'clear'/'compact' re-creation legitimately re-runs the
|
|
792
|
+
// chain on its fresh entry and teardown has nothing to re-arm. Only
|
|
793
|
+
// { agent } is structural; the rest is defensive.
|
|
794
|
+
//
|
|
795
|
+
// BOOT GUARANTEE (dsh 0.1.7): the serial dispatch awaits every
|
|
796
|
+
// `agent/created` listener and a throw or rejection ROLLS THE ENTIRE AGENT
|
|
797
|
+
// CREATION BACK. This listener is therefore total and synchronous — it
|
|
798
|
+
// never throws, never returns a promise — and the chain is deferred off
|
|
799
|
+
// the creation transaction entirely (setImmediate, unref'd): the pull /
|
|
800
|
+
// replay-distill / consolidation slow work starts only after the serial
|
|
801
|
+
// dispatch has moved on. Plugin initialization must never block, delay,
|
|
802
|
+
// or abort session startup; a chain failure at most warns.
|
|
635
803
|
const runBootChain = (sessionId: string) => {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// Failures are NOT silent at the host log: the 2026-09-09 real-host
|
|
667
|
-
// test showed a dead distill route would otherwise hide here with
|
|
668
|
-
// zero observable trace (the state file only advances on success).
|
|
669
|
-
try {
|
|
670
|
-
captureFromAgent(agents()?.get(sessionId) as unknown)
|
|
671
|
-
} catch {
|
|
672
|
-
// contained — the run below fails with a readable no-model detail
|
|
673
|
-
}
|
|
674
|
-
void consolidator
|
|
675
|
-
.maybeRun({ sessionId })
|
|
676
|
-
.then((r) => {
|
|
677
|
-
if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
|
|
678
|
-
warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
|
|
679
|
-
}
|
|
680
|
-
})
|
|
681
|
-
.catch(() => undefined)
|
|
682
|
-
// Deprecated-TTL sweep — pure local rule, no model, runs even when
|
|
683
|
-
// the distill route is unconfigured. Drops are logged: deletion is
|
|
684
|
-
// the one housekeeping action the user should always see happened.
|
|
685
|
-
const ttl = cfgNow().deprecatedTtlDays
|
|
686
|
-
if (ttl > 0) {
|
|
687
|
-
void dropExpiredDeprecated(service, ttl)
|
|
688
|
-
.then((dropped) => {
|
|
689
|
-
if (dropped.length > 0) {
|
|
690
|
-
warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
|
|
804
|
+
// Contained at the top too: a synchronous prologue throw (config read,
|
|
805
|
+
// sync bookkeeping) must degrade to a warn, not leak toward the host.
|
|
806
|
+
try {
|
|
807
|
+
void sync
|
|
808
|
+
.pull()
|
|
809
|
+
.catch((error: unknown) => {
|
|
810
|
+
// The pull failing must stay non-fatal for the chain (replay and
|
|
811
|
+
// TTL still run on the local pool), but no longer silent — a dead
|
|
812
|
+
// sync route should leave a host-log trace.
|
|
813
|
+
warn(`dsh-topics-memory 启动同步 pull 失败(继续本地启动链):${error instanceof Error ? error.message : String(error)}`)
|
|
814
|
+
})
|
|
815
|
+
.finally(() => store.ensure().catch(() => undefined))
|
|
816
|
+
.then(() => {
|
|
817
|
+
// Boot replay: the observations JSONL is the durable distill queue.
|
|
818
|
+
// Whatever the previous exit skipped (local-only exit since the
|
|
819
|
+
// no-network exit commit) or a killed run left unmarked is still
|
|
820
|
+
// undistilled here. Empty pool and no-model are cheap no-ops inside
|
|
821
|
+
// the distiller; the per-session dedup guards double runs.
|
|
822
|
+
void store
|
|
823
|
+
.undistilledObservations(1)
|
|
824
|
+
.then((pending) => {
|
|
825
|
+
if (pending.length === 0) return
|
|
826
|
+
// Same trigger-time capture the observer callback does: the
|
|
827
|
+
// replay run's caller resolves candidates lazily and a fresh
|
|
828
|
+
// session has no prior capture to lean on.
|
|
829
|
+
try {
|
|
830
|
+
captureFromAgent(agents()?.get(sessionId) as unknown)
|
|
831
|
+
} catch {
|
|
832
|
+
// contained — the run below fails with the readable
|
|
833
|
+
// no-adapter detail instead
|
|
691
834
|
}
|
|
835
|
+
const run = distiller.request(sessionId, 'boot-replay')
|
|
836
|
+
if (run !== undefined) void run.catch(() => undefined)
|
|
692
837
|
})
|
|
693
838
|
.catch(() => undefined)
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
839
|
+
// Consolidation cadence check — after the pull (freshest pool),
|
|
840
|
+
// fully fire-and-forget: cadence/single-flight gates live inside
|
|
841
|
+
// maybeRun, and a no-model boot just skips (no stamp advanced).
|
|
842
|
+
// Failures are NOT silent at the host log: the 2026-09-09 real-host
|
|
843
|
+
// test showed a dead distill route would otherwise hide here with
|
|
844
|
+
// zero observable trace (the state file only advances on success).
|
|
845
|
+
try {
|
|
846
|
+
captureFromAgent(agents()?.get(sessionId) as unknown)
|
|
847
|
+
} catch {
|
|
848
|
+
// contained — the run below fails with a readable no-model detail
|
|
849
|
+
}
|
|
850
|
+
void consolidator
|
|
851
|
+
.maybeRun({ sessionId })
|
|
852
|
+
.then((r) => {
|
|
853
|
+
if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
|
|
854
|
+
warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
|
|
855
|
+
}
|
|
856
|
+
})
|
|
857
|
+
.catch(() => undefined)
|
|
858
|
+
// Deprecated-TTL sweep — pure local rule, no model, runs even when
|
|
859
|
+
// the distill route is unconfigured. Drops are logged: deletion is
|
|
860
|
+
// the one housekeeping action the user should always see happened.
|
|
861
|
+
const ttl = cfgNow().deprecatedTtlDays
|
|
862
|
+
if (ttl > 0) {
|
|
863
|
+
void dropExpiredDeprecated(service, ttl)
|
|
864
|
+
.then((dropped) => {
|
|
865
|
+
if (dropped.length > 0) {
|
|
866
|
+
warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
.catch(() => undefined)
|
|
870
|
+
}
|
|
871
|
+
})
|
|
872
|
+
.catch((error: unknown) => {
|
|
873
|
+
warn(`dsh-topics-memory 启动链失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
|
|
874
|
+
})
|
|
875
|
+
} catch (error) {
|
|
876
|
+
warn(`dsh-topics-memory 启动链无法启动(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
|
|
877
|
+
}
|
|
697
878
|
}
|
|
698
|
-
|
|
699
|
-
|
|
879
|
+
ctx.on('agent/created' as never, ((payload: { agent?: { id?: unknown } }) => {
|
|
880
|
+
// The boot-fallback contract lives HERE, not inside runBootChain: dsh
|
|
881
|
+
// 0.1.7 rolls agent creation back when this listener throws, so even a
|
|
882
|
+
// hostile/malformed payload (a throwing getter, a proxy trap) degrades to
|
|
883
|
+
// a warn. Returning undefined (never a thenable) keeps the serial
|
|
884
|
+
// dispatch from awaiting anything.
|
|
885
|
+
try {
|
|
700
886
|
const sessionId = String(payload?.agent?.id ?? '')
|
|
701
|
-
if (sessionId === ''
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
887
|
+
if (sessionId === '') return
|
|
888
|
+
// Off the creation transaction: the unref'd immediate fires after the
|
|
889
|
+
// current serial dispatch slice, so the chain's synchronous prologue
|
|
890
|
+
// (config read, sync state) never delays the remaining listeners nor
|
|
891
|
+
// the loop start, and the timer cannot hold a dying process open.
|
|
892
|
+
const deferred = setImmediate(() => runBootChain(sessionId))
|
|
893
|
+
deferred.unref?.()
|
|
894
|
+
} catch (error) {
|
|
895
|
+
warn(`dsh-topics-memory boot 链触发失败(不影响会话启动):${error instanceof Error ? error.message : String(error)}`)
|
|
896
|
+
}
|
|
897
|
+
}) as never)
|
|
706
898
|
|
|
707
899
|
ctx.effect(
|
|
708
900
|
() => {
|
|
@@ -736,11 +928,13 @@ export function apply(ctx: Context): void {
|
|
|
736
928
|
const commands = (cmdCtx as unknown as { commands?: { register(definition: unknown): () => void } }).commands
|
|
737
929
|
if (commands === undefined || commands.register === undefined) return
|
|
738
930
|
const settingsMutator = (ctx as unknown as {
|
|
739
|
-
settings?: { mutate?: (ns:
|
|
931
|
+
settings?: { mutate?: (ns: string, ops: readonly { op: 'set'; path: string[]; value?: unknown }[], expected?: number) => Promise<void> }
|
|
740
932
|
}).settings
|
|
741
933
|
const mutate = async (ops: readonly { op: 'set'; path: string[]; value?: unknown }[]): Promise<void> => {
|
|
742
934
|
if (settingsMutator?.mutate === undefined) throw new Error('settings 服务不可用,无法写入配置')
|
|
743
|
-
|
|
935
|
+
// 0.1.7: the settings namespace is the profile entry id, not the old
|
|
936
|
+
// `topics` namespace name.
|
|
937
|
+
await settingsMutator.mutate(ENTRY_ID, ops)
|
|
744
938
|
}
|
|
745
939
|
// Resolved lazily at invocation time: whichever UI registered the ask-user
|
|
746
940
|
// provider (TUI panel / web composer / feishu card, ask-router optional)
|