@falling-ts/dsh-force-compact 0.2.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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Live UI status messenger — the plugin-private bridge between the HOST half
3
+ * ("which phase am I in right now?") and the CLIENT half ("paint THAT pair on
4
+ * the `TurnStatus` DOM node").
5
+ *
6
+ * Why settings at all
7
+ * ------------------
8
+ * The client half (`web/client.js`) already mirrors the `falling-ts-force-compact`
9
+ * namespace through `settingsScope.bind` → `createSnapshotStore`, so ANY field
10
+ * written by the host here is reflected in the browser live (the `SettingsScope`
11
+ * revision-fencing contract). That is the ONLY sanctioned host→browser live-data
12
+ * channel this independent plugin bundle can use — there is no reverse RPC seam
13
+ * for a client-loaded plugin to expose arbitrary callable methods (the unary
14
+ * route map is closed and host-defined). The `liveUi` field documented below is
15
+ * therefore a PLUGIN-PRIVATE transient messenger: the host is its only writer;
16
+ * the client never writes it; it deliberately persists to `settings.yaml` like
17
+ * every other field of the namespace (harmless cosmetic residue — the worst
18
+ * outcome after a restart is the badge briefly showing a stale phase before the
19
+ * next LLM call overwrites it).
20
+ *
21
+ * Phases
22
+ * ------
23
+ * Every model request begins with a WORKING phase (a random text+color pair
24
+ * drawn from the 20×20 tables below — the "工作中的状态" set). Around a
25
+ * force-compaction the display is OVERRIDDEN deterministically, not randomly:
26
+ *
27
+ * • COMPRESSING — pinned red `[强制压缩中>>>]`, fired BEFORE the `compactNow` /
28
+ * `compactRegion` call;
29
+ * • DONE — pinned green `[压缩完成!]`, fired right AFTER the call
30
+ * commits; then after `DONE_FALLBACK_MS` (3 s) a forced
31
+ * `isImportant=true` push redraws a fresh random Deep working pair
32
+ * (single-purpose timer — the only one in this module).
33
+ *
34
+ * All writers are GUARANTEED never to throw (they wrap the settings-service
35
+ * write in try/catch): a messenger failure must NEVER disrupt the model
36
+ * request or the compaction transaction itself.
37
+ *
38
+ * @module @falling-ts/dsh-force-compact/ui-signal
39
+ */
40
+
41
+ /** The settings field name carrying the live UI status (host-written, client-read). */
42
+ export const LIVE_UI_FIELD = 'liveUi'
43
+
44
+ /** Phase discriminants. Closed union — consumers switch on exactly these. */
45
+ export const PHASE_WORKING = 'working'
46
+ export const PHASE_COMPRESSING = 'compressing'
47
+ export const PHASE_DONE = 'done'
48
+
49
+ /** Pinned (never randomized) payloads for the deterministic phases. */
50
+ export const PINNED_TEXTS = Object.freeze({
51
+ [PHASE_COMPRESSING]: '[强制压缩中>>>]',
52
+ [PHASE_DONE]: '[压缩完成!]',
53
+ })
54
+
55
+ /** Pinned colors matching {@link PINNED_TEXTS} — red while compacting, green on completion. */
56
+ export const PINNED_COLORS = Object.freeze({
57
+ [PHASE_COMPRESSING]: '#ff4d4f',
58
+ [PHASE_DONE]: '#52c41a',
59
+ })
60
+
61
+ /**
62
+ * The 20 WORKING-phase texts. Deliberately irreverent, meme-flavored one-liners
63
+ * aimed at the agent ITSELF ("我正在憋大招..." / "我在偷渡..." / ...) — the
64
+ * badge talks about what the agent is supposedly up to in a playful voice
65
+ * instead of dry status verbs. Lengths intentionally exceed the old four-char
66
+ * constraint; the client paints the raw string with no width assumption.
67
+ * Colors are unchanged and remain randomly paired with these labels.
68
+ * @readonly
69
+ */
70
+ export const WORKING_TEXTS = Object.freeze([
71
+ '正在酝酿骚操作...',
72
+ '正在憋大招...',
73
+ '灵感正在路上...',
74
+ '脑细胞开会中...',
75
+ '灵魂拷问进行中...',
76
+ '偷偷翻你底牌...',
77
+ '量子纠缠计算中...',
78
+ '假装很忙...',
79
+ '摸鱼式工作中...',
80
+ '疯狂敲键盘(精神上)...',
81
+ '正在缝合上下文...',
82
+ '正在驯服混沌...',
83
+ '正在召唤赛博大脑...',
84
+ '正在翻阅《天机》...',
85
+ 'CPU 正在冒烟...',
86
+ '正在跟熵值搏斗...',
87
+ '正在画饼给你吃...',
88
+ '正在偷渡灵感...',
89
+ '正在暗中观察...',
90
+ '马上就好(大概)...',
91
+ ])
92
+
93
+ /**
94
+ * The 20 WORKING-phase colors — a fixed palette covering the hue wheel (soft
95
+ * blues/greens for calm phases, warm amber/violet toward the end); each pairs
96
+ * with any text independently (random pairing, not a locked text-color index,
97
+ * so repeated draws visibly vary BOTH dimensions).
98
+ * @readonly
99
+ */
100
+ export const WORKING_COLORS = Object.freeze([
101
+ '#4f9cf9',
102
+ '#5b8def',
103
+ '#6a5bff',
104
+ '#8b5cf6',
105
+ '#a855f7',
106
+ '#c45bf9',
107
+ '#db6bd4',
108
+ '#e86bb0',
109
+ '#f06b8b',
110
+ '#f76b5b',
111
+ '#fb8c5b',
112
+ '#fca95b',
113
+ '#fdc35b',
114
+ '#d8e05b',
115
+ '#aede5b',
116
+ '#7ee083',
117
+ '#5be0a0',
118
+ '#5becd8',
119
+ '#5bcdf9',
120
+ '#7ba8f9',
121
+ ])
122
+
123
+ /**
124
+ * Draw a random working-phase status: a random text paired with a random
125
+ * color (independently chosen, so the pair space is 20×20 = 400 distinct
126
+ * combinations). Pure — no I/O, trivially testable.
127
+ * @returns {{phase: string, text: string, color: string}}
128
+ */
129
+ export function randomWorkingPair() {
130
+ return {
131
+ phase: PHASE_WORKING,
132
+ text: WORKING_TEXTS[Math.floor(Math.random() * WORKING_TEXTS.length)],
133
+ color: WORKING_COLORS[Math.floor(Math.random() * WORKING_COLORS.length)],
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Build the pinned payload for a deterministic phase.
139
+ * @param {'compressing'|'done'} phase
140
+ * @returns {{phase: string, text: string, color: string}}
141
+ */
142
+ export function pinnedPayload(phase) {
143
+ return { phase, text: PINNED_TEXTS[phase], color: PINNED_COLORS[phase] }
144
+ }
145
+
146
+ /**
147
+ * Publish one UI status onto the `liveUi` field of the `falling-ts-force-compact`
148
+ * namespace. This is THE host→browser delivery point: the client half's
149
+ * `settingsScope.bind` mirror flips its snapshot on the next accepted
150
+ * revision, and the browser component repaints the `TurnStatus` DOM node.
151
+ *
152
+ * Guarantees:
153
+ * • NEVER throws — a settings-service absence or a rejected write is caught
154
+ * and logged at most once per lifetime (observability only; the model
155
+ * request and any surrounding compaction transaction proceed untouched).
156
+ * • Fire-and-forget from the caller's perspective: the returned promise
157
+ * always settles (resolve on success, resolve-with-warning on failure).
158
+ *
159
+ * @param {import('@deepseek-ai/cordis').Context} ctx
160
+ * @param {{phase: string, text: string, color: string}} status
161
+ * @param {boolean} [isImportant=false] — `true` bypasses the guard entirely
162
+ * and writes unconditionally. `false` (the default) refuses to overwrite a
163
+ * currently displayed text that starts with `[` (i.e. a pinned bracket-form
164
+ * message such as `[强制压缩中>>>]`), returning early without touching
165
+ * settings.
166
+ * @returns {Promise<void>}
167
+ */
168
+ let warnedOnce = false
169
+ export async function publishUiStatus(ctx, status, isImportant = false) {
170
+ try {
171
+ const settings = ctx.get('settings')
172
+ if (settings === undefined || typeof settings.update !== 'function') return
173
+ const NS = 'falling-ts-force-compact'
174
+ // Non-important pushes refuse to overwrite a pinned bracket-form text.
175
+ if (!isImportant) {
176
+ let currentText
177
+ try {
178
+ // SYNC read — the settings service's `get` returns the cached value
179
+ // immediately (same call style as settings.js:226); the gate is purely
180
+ // advisory (worst case: the write proceeds, nothing breaks), so there
181
+ // is no reason to await an async variant even if one ever appeared.
182
+ const nsValue = (typeof settings.get === 'function') ? settings.get(NS) : undefined
183
+ currentText = (nsValue != null && typeof nsValue === 'object')
184
+ ? nsValue[LIVE_UI_FIELD]?.text
185
+ : (typeof nsValue === 'string' ? nsValue : undefined)
186
+ } catch { /* read failure must not block the important path — fall through */ }
187
+ if (typeof currentText === 'string' && currentText.startsWith('[')) return
188
+ }
189
+ await settings.update(NS, { [LIVE_UI_FIELD]: status })
190
+ if (!warnedOnce) {
191
+ warnedOnce = true
192
+ try {
193
+ ctx.logger.debug(`[force-compact] ui-signal: publishing ${status?.phase} "${status?.text}" (${status?.color}) via ${NS}.${LIVE_UI_FIELD}`)
194
+ } catch { /* logging must never propagate */ }
195
+ }
196
+ } catch (error) {
197
+ const message = error instanceof Error ? error.message : String(error)
198
+ try {
199
+ ctx.logger.warn(`[force-compact] ui-signal publish failed (ignored, cosmetic only) — ${message}`)
200
+ } catch { /* never */ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The host-side driver for one MODEL REQUEST's start moment. Call from the
206
+ * `llm/stream` waterfall (once per outgoing call): draw a fresh random working
207
+ * pair and publish it NON-importantly (so a pinned bracket-form text such as
208
+ * `[压缩完成!]` survives the push until its own fallback timer clears it — see
209
+ * {@link publishDone}). A fresh pair is also emitted on the 3-second fallback
210
+ * after DONE ({@link DONE_FALLBACK_MS}), firing with `isImportant=true` so it
211
+ * overwrites the DONE banner even though a `[`-prefixed text is on screen.
212
+ * @param {import('@deepseek-ai/cordis').Context} ctx
213
+ * @returns {Promise<void>}
214
+ */
215
+ export async function publishRandomWorking(ctx) {
216
+ await publishUiStatus(ctx, randomWorkingPair())
217
+ }
218
+
219
+ /**
220
+ * Publish the pinned RED "[强制压缩中>>>]" status. Call BEFORE a `compactNow` /
221
+ * `compactRegion` invocation. Passes `isImportant=true` so the pinned
222
+ * bracket-form message can overwrite whatever is currently displayed (including
223
+ * another pinned text).
224
+ * @param {import('@deepseek-ai/cordis').Context} ctx
225
+ * @returns {Promise<void>}
226
+ */
227
+ export async function publishCompressing(ctx) {
228
+ await publishUiStatus(ctx, pinnedPayload(PHASE_COMPRESSING), true)
229
+ }
230
+
231
+ /**
232
+ * How long after the pinned "[压缩完成!]" DONE banner is published before the
233
+ * forced fallback kicks in and paints a fresh random Deep working pair back on
234
+ * the badge (with `isImportant=true`). 3000 ms.
235
+ *
236
+ * Note: this is the plugin's SINGLE-USE TIMER — a deliberate, documented
237
+ * exception to the collection rule "plugins are pure host listeners that do
238
+ * not introduce timers". See the `dsh-force-compact` AGENTS.md deviation note.
239
+ */
240
+ const DONE_FALLBACK_MS = 3000
241
+
242
+ /**
243
+ * Publish the pinned GREEN "[压缩完成!]" status. Call AFTER a `compactNow` /
244
+ * `compactRegion` invocation commits. After {@link DONE_FALLBACK_MS} (3 s) a
245
+ * forced fallback (`isImportant=true`) overwrites the DONE banner with a fresh
246
+ * random Deep working pair, restoring the usual working appearance regardless
247
+ * of whether a subsequent `llm/stream` fires in the interim. We paint a
248
+ * FRESH random working pair (not the literal pre-compression text, which is no
249
+ * longer recoverable from the settings store — it was already overwritten by
250
+ * the COMPRESSING/DONE banners): the intent is "back to a normal working
251
+ * look", which a freshly-drawn working pair satisfies.
252
+ *
253
+ * Passes `isImportant=true` for the initial DONE push: reaching `publishDone`
254
+ * PRECEDED BY `publishCompressing`, which has already written the pinned red
255
+ * `[强制压缩中>>>]` bracket-form text. Without `isImportant=true` the gate inside
256
+ * {@link publishUiStatus} (which refuses non-important pushes over a currently
257
+ * displayed `[`-bracket text) would see our OWN still-displayed `compressing`
258
+ * banner and bail out, so the green `[压缩完成!]` would silently never be written
259
+ * and the 3 s fallback would jump straight from COMPRESSING to a fresh working
260
+ * pair — the DONE banner never appearing at all. Since a DONE push can only
261
+ * follow a `compressing` push from THIS plugin, there is no manually-set custom
262
+ * banner to protect, and overriding our own prior banner is exactly intended.
263
+ * @param {import('@deepseek-ai/cordis').Context} ctx
264
+ * @returns {Promise<void>}
265
+ */
266
+ export async function publishDone(ctx) {
267
+ await publishUiStatus(ctx, pinnedPayload(PHASE_DONE), true)
268
+ setTimeout(() => {
269
+ void publishUiStatus(ctx, randomWorkingPair(), true)
270
+ }, DONE_FALLBACK_MS)
271
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Locate the `compaction` service for a given agent.
3
+ *
4
+ * In modern harness compositions the compaction BACKEND (`compaction-basic`)
5
+ * is mounted per **agent preset realm** (the `standard` preset isolates the
6
+ * `compaction` service into each session's realm), so the HOST-GLOBAL
7
+ * `ctx.get('compaction')` observes `undefined` while each live agent's own
8
+ * context (`agent.ctx`) resolves the instance. A host-level function plugin
9
+ * (this one) holds a global context, so reading `ctx.get('compaction')` alone
10
+ * finds nothing — but it DOES have the live agent handle on every event
11
+ * payload, and `agent.ctx` is the canonical way to reach that agent's
12
+ * realm-scoped world.
13
+ *
14
+ * The location MODE comes from the `compactionMode` setting (`'realm'`
15
+ * default, `'global'` opt-out), read directly from the raw settings namespace
16
+ * so the hot model-request path never pays a full-settings-parse cost.
17
+ *
18
+ * Resolution tries, in priority order:
19
+ * 1. `agent.ctx` (the agent's realm-scoped context, guaranteed by the
20
+ * `Agent` type definition) — the modern preset-plane location;
21
+ * 2. the host-global `ctx.get('compaction')` — the base-bundle location
22
+ * plus a safety net when the agent context did not hold the service;
23
+ * 3. `ctx.compaction` (injected-property convention, topology-sensitive).
24
+ *
25
+ * `'realm'` (default) tries all three so every layout works; `'global'`
26
+ * restricts to steps 2–3 (a deployment known to mount the backend globally).
27
+ *
28
+ * @module @falling-ts/dsh-force-compact/service-resolver
29
+ */
30
+
31
+ import { readRawSetting, COMPACT_MODE_GLOBAL } from '../core/settings.js'
32
+
33
+ import { compactNowBuiltin, compactRegionBuiltin } from './builtin.js'
34
+ import { guardFn } from '../core/crashnet.js'
35
+
36
+ /**
37
+ * Find a usable compaction backend for one agent.
38
+ *
39
+ * Resolution order (priority 1 first):
40
+ * 1. the OFFICIAL `compaction` service (`compactNow`/`compactRegion`) — the
41
+ * authoritative summarizer; preferred whenever it is reachable from this
42
+ * agent's context (see the historical note above about realm placement);
43
+ * 2. this plugin's BUILTIN engine (its own transaction, event names, and
44
+ * summarizer) — the fallback used whenever the official service is
45
+ * unreachable from this context (the common `standard` preset layout) and
46
+ * the `builtinEnabled` setting allows it (default `true`).
47
+ *
48
+ * Both backends expose the SAME shape — `compactNow` and `compactRegion` — so
49
+ * callers are agnostic to which produced the result.
50
+ *
51
+ * @param {import('@deepseek-ai/cordis').Context} ctx a context to fall back to (the plugin-global context) when the agent-side candidates do not resolve.
52
+ * @param {import('@deepseek-ai/dsh-agent').Agent|undefined} agent the agent owning the target session.
53
+ * @param {string|undefined} mode the `compactionMode` setting value (`'realm'`|`'global'`).
54
+ * @returns {Promise<{ compactNow: Function, compactRegion: Function, kind: 'official'|'builtin' }|undefined>} a normalized backend, or `undefined` when neither the official service nor the builtin engine is usable.
55
+ */
56
+ // Internal body of `resolveCompaction` — routed through the crash-net wrapper
57
+ // so an unusual throw (a throwing `ctx.get` proxy, a rejecting `readRawSetting`)
58
+ // becomes a visible diagnostic rather than a silent propagation.
59
+ async function __resolveCompactionBody(ctx, agent, mode) {
60
+ const official = await findOfficialService(ctx, agent, mode)
61
+ if (official !== undefined) {
62
+ return { compactNow: official.compactNow, compactRegion: official.compactRegion, kind: 'official' }
63
+ }
64
+ return await builtinBackend(ctx, agent)
65
+ }
66
+
67
+ /** Public entry — wrapped by the universal crash net. */
68
+ export const resolveCompaction = guardFn('backend.resolveCompaction', __resolveCompactionBody)
69
+
70
+ /**
71
+ * Locate the OFFICIAL `compaction` service for this agent via the historical
72
+ * two-tier resolver. Reads the `compactionMode` setting (`'realm'` default,
73
+ * `'global'` opt-out) to choose candidates:
74
+ * - `'realm'` (default): the agent's OWN realm-scoped context (`agent.ctx`,
75
+ * the canonical modern location), then the host-global `ctx.get('compaction')`;
76
+ * - `'global'`: only the host-global `ctx.get('compaction')`.
77
+ *
78
+ * NEVER declares `inject:['compaction']` and NEVER reads `ctx.compaction` as a
79
+ * property (that would trip the strict-injection fatal). Only `ctx.get` /
80
+ * `agent.ctx.get` — both safe and tolerant.
81
+ *
82
+ * @param {import('@deepseek-ai/cordis').Context} ctx
83
+ * @param {import('@deepseek-ai/dsh-agent').Agent|undefined} agent
84
+ * @param {string|undefined} mode
85
+ * @returns {Promise<object|undefined>} the official compaction service, or `undefined`.
86
+ */
87
+ async function findOfficialService(ctx, agent, mode) {
88
+ const effectiveMode = (typeof mode === 'string' && mode === COMPACT_MODE_GLOBAL)
89
+ ? COMPACT_MODE_GLOBAL
90
+ : ((mode === undefined) ? (await readRawSetting(ctx, 'compactionMode')) : 'realm')
91
+
92
+ if (effectiveMode !== COMPACT_MODE_GLOBAL
93
+ && agent && agent.ctx !== undefined && agent.ctx !== null) {
94
+ const byRealm = tryGet(agent.ctx)
95
+ if (byRealm !== undefined) return byRealm
96
+ }
97
+ return tryGet(ctx)
98
+ }
99
+
100
+ /**
101
+ * Build the BUILTIN backend for this agent: the plugin's own engine, gated by
102
+ * the `builtinEnabled` setting (default `true`). Returns `undefined` when the
103
+ * engine is disabled or lacks what it needs (an `agent` handle with a session
104
+ * and an `llm` service for the summarizer).
105
+ */
106
+ async function builtinBackend(ctx, agent) {
107
+ const enabled = (await readRawSetting(ctx, 'builtinEnabled')) ?? true
108
+ if (!enabled) return undefined
109
+ if (agent === undefined || agent === null || agent.session === undefined) return undefined
110
+ const llm = ctx.get('llm')
111
+ if (llm === undefined || typeof llm.stream !== 'function') return undefined
112
+ return {
113
+ kind: 'builtin',
114
+ // P1 — widen positionals so command-driven callers can thread
115
+ // `sourceCommandId` (3rd arg) and auto/idle callers can supply
116
+ // `opts` (4th arg) to the builtin engine. The official passthrough
117
+ // (priority-1 path) ignores extra positionals harmlessly.
118
+ compactNow: (...args) => compactNowBuiltin(ctx, ...args),
119
+ compactRegion: (...args) => compactRegionBuiltin(ctx, ...args),
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Safely attempt `receiver.get('compaction')`, returning the service ONLY when
125
+ * it exposes the methods this plugin calls (`compactNow` / `compactRegion`).
126
+ * Never throws — an unusable receiver or a missing service yields `undefined`.
127
+ *
128
+ * @param {any} receiver any receiver (typically a Cordis context, possibly a
129
+ * Proxy; some partial receivers may lack `.get` altogether).
130
+ * @returns {object|undefined}
131
+ */
132
+ function tryGet(receiver) {
133
+ if (receiver === undefined || receiver === null) return undefined
134
+ if (typeof receiver.get !== 'function') return undefined
135
+ try {
136
+ const svc = receiver.get('compaction')
137
+ if (svc === undefined || svc === null) return undefined
138
+ if (typeof svc.compactNow === 'function' || typeof svc.compactRegion === 'function') return svc
139
+ } catch {
140
+ // Any receiver shape (including non-proxy partials) is tolerated.
141
+ }
142
+ return undefined
143
+ }