dsh-engram 0.2.0 → 0.3.2
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 +115 -5
- package/README.zh.md +60 -4
- package/lib/api.js +91 -10
- package/lib/capture.js +11 -7
- package/lib/client.js +4590 -1057
- package/lib/index-block.js +163 -27
- package/lib/index.js +43 -1
- package/lib/mental.js +170 -0
- package/lib/obs.js +71 -0
- package/lib/rerank.js +50 -0
- package/lib/settings.js +3 -0
- package/lib/store.js +330 -32
- package/lib/tools.js +349 -62
- package/lib/trigger.js +379 -0
- package/lib/usage.js +224 -60
- package/lib/util.js +107 -0
- package/package.json +3 -7
package/lib/trigger.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engram: decision-point ESR triggers (P0-A + P1).
|
|
3
|
+
*
|
|
4
|
+
* The old mem-vs-esr escalation nudge (`escalationHint`) read the per-
|
|
5
|
+
* (workspace, day) `usage` table, but assessment P3 removed the per-call
|
|
6
|
+
* write path (observability moved to on-demand session-log scans), leaving
|
|
7
|
+
* the nudge with a dead data source. This module restores that balance
|
|
8
|
+
* signal with a cheap in-memory counter fed by the SAME tools/result
|
|
9
|
+
* subscription — zero I/O per prompt render, zero LLM — and adds the P0-A
|
|
10
|
+
* decision-point trigger:
|
|
11
|
+
*
|
|
12
|
+
* Watch the native `todo_write` calls, snapshot the session's pending
|
|
13
|
+
* todos, and let the [ESR] block emit a concrete "promote to esr_task"
|
|
14
|
+
* hint — at most once per session, only when the funnel is actually
|
|
15
|
+
* narrow (pending todos > active ESR tasks). This mirrors the GUI's
|
|
16
|
+
* "沉淀到 ESR" button on the model side, firing exactly where the
|
|
17
|
+
* todo→esr routing decision happens.
|
|
18
|
+
*
|
|
19
|
+
* All state is process-local (resets on host reload — acceptable for a
|
|
20
|
+
* nudge, same trade-off as a short-lived cache). Fully contained: handle()
|
|
21
|
+
* never throws, so a trigger bug can never disturb the tool pipeline.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { workspaceKey as wk, truncate } from "./util.js";
|
|
25
|
+
import { dayKey } from "./usage.js";
|
|
26
|
+
|
|
27
|
+
/** Tools that count as memory-ops in the mem-vs-esr balance. */
|
|
28
|
+
export const MEM_TOOLS = [
|
|
29
|
+
"engram_store",
|
|
30
|
+
"engram_recall",
|
|
31
|
+
"engram_detail",
|
|
32
|
+
"loom_store",
|
|
33
|
+
"loom_recall",
|
|
34
|
+
"loom_detail",
|
|
35
|
+
];
|
|
36
|
+
/** Tools that count as ESR-ops in the mem-vs-esr balance. */
|
|
37
|
+
export const ESR_TOOLS = ["esr_task", "esr_node", "esr_close", "esr_link", "esr_gc"];
|
|
38
|
+
|
|
39
|
+
/** Keep at most this many workspace snapshots (todo + usage) in memory. */
|
|
40
|
+
const WORKSPACE_CAP = 200;
|
|
41
|
+
/** Balance rolling window (days). */
|
|
42
|
+
const BALANCE_DAYS = 14;
|
|
43
|
+
const DAY_MS = 86_400_000;
|
|
44
|
+
/** Healthy once esr exceeds ~a third of memory-ops volume (matches P0-era). */
|
|
45
|
+
const HEALTHY_RATIO = 0.34;
|
|
46
|
+
/** Under this many memory ops a nudge is lecturing noise. */
|
|
47
|
+
const SAMPLE_FLOOR = 3;
|
|
48
|
+
|
|
49
|
+
/** Statuses a todo item wears when it is still unfinished. */
|
|
50
|
+
const DONE_STATUSES = new Set(["completed", "done", "complete", "closed"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stable per-hint source tags (P4). Appended to every nudge line so the
|
|
54
|
+
* model can reference them and any log watcher can attribute which hint was
|
|
55
|
+
* shown; `emitHint`/`conversionStats` measure hint → esr_* action conversion.
|
|
56
|
+
*/
|
|
57
|
+
export const HINT_TAGS = {
|
|
58
|
+
promote: "#suggest-promote",
|
|
59
|
+
rootcause: "#suggest-rootcause",
|
|
60
|
+
close: "#suggest-close",
|
|
61
|
+
stale: "#suggest-stale",
|
|
62
|
+
escalate: "#suggest-escalate",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A hint counts as "converted" when an esr_* tool runs within this window. */
|
|
66
|
+
const CONVERSION_WINDOW_MS = 10 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Deterministically extract the list of unfinished todo names from a
|
|
70
|
+
* `todo_write` arguments object. Never throws; returns [] for anything else.
|
|
71
|
+
*/
|
|
72
|
+
export function pendingTodos(args) {
|
|
73
|
+
if (args === null || typeof args !== "object") return [];
|
|
74
|
+
const raw = Array.isArray(args.todos) ? args.todos : [];
|
|
75
|
+
const names = [];
|
|
76
|
+
for (const item of raw) {
|
|
77
|
+
if (item === null || item === void 0) continue;
|
|
78
|
+
if (typeof item === "string") {
|
|
79
|
+
const c = item.trim();
|
|
80
|
+
if (c.length > 0) names.push(c);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (typeof item !== "object") continue;
|
|
84
|
+
const status = String(item.status ?? item.state ?? "").toLowerCase();
|
|
85
|
+
if (DONE_STATUSES.has(status)) continue;
|
|
86
|
+
const content = String(item.content ?? item.name ?? item.title ?? "").trim();
|
|
87
|
+
if (content.length > 0) names.push(content);
|
|
88
|
+
}
|
|
89
|
+
return names;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The decision-point recorder. Feed every tools/result event to `handle()`;
|
|
94
|
+
* read back the live balance via `recentBalance(workspace)` and the one-shot
|
|
95
|
+
* promote prompt via `promoteHint(sessionId, workspace, activeTasks)`.
|
|
96
|
+
*/
|
|
97
|
+
export function makeTriggerRecorder(config = {}) {
|
|
98
|
+
const minTodosForPromote = config.minTodosForPromote ?? 2;
|
|
99
|
+
const todoPromoteRatio = config.todoPromoteRatio ?? 1;
|
|
100
|
+
/** workspace -> Map<day, {mem, esr}> (live balance, replaces dead usage table). */
|
|
101
|
+
const usage = new Map();
|
|
102
|
+
/** workspace -> { sessionId, count, names, updatedAt } (pending-todo plan). */
|
|
103
|
+
const todos = new Map();
|
|
104
|
+
/** `ws::sessionId` already got its promote hint this session. */
|
|
105
|
+
const promoted = new Set();
|
|
106
|
+
/** `ws::sessionId::errId` — root-cause hint already shown for that mem. */
|
|
107
|
+
const errorSeen = new Set();
|
|
108
|
+
/** `ws::sessionId` — its todos went pending → 0 (work finished). */
|
|
109
|
+
const workDone = new Set();
|
|
110
|
+
/** `ws::sessionId` — closure hint already shown. */
|
|
111
|
+
const closureSeen = new Set();
|
|
112
|
+
/** `ws::sessionId::taskId` — stale hint already shown for that task. */
|
|
113
|
+
const staleSeen = new Set();
|
|
114
|
+
/** `ws::sessionId::kind` — hint already emitted for that session (P4). */
|
|
115
|
+
const hintSeen = new Set();
|
|
116
|
+
/** ws -> Map<kind, shown|converted> — conversion measurement (P4). */
|
|
117
|
+
const shownCounts = new Map();
|
|
118
|
+
const convertedCounts = new Map();
|
|
119
|
+
/** Unconverted hints within the window: {kind, ws, session, shownAt}. */
|
|
120
|
+
const pending = [];
|
|
121
|
+
|
|
122
|
+
function bump(workspace, key) {
|
|
123
|
+
const day = dayKey();
|
|
124
|
+
let buckets = usage.get(workspace);
|
|
125
|
+
if (buckets === void 0) {
|
|
126
|
+
buckets = new Map();
|
|
127
|
+
usage.set(workspace, buckets);
|
|
128
|
+
}
|
|
129
|
+
const cur = buckets.get(day) ?? { mem: 0, esr: 0 };
|
|
130
|
+
cur[key] += 1;
|
|
131
|
+
buckets.set(day, cur);
|
|
132
|
+
if (buckets.size > BALANCE_DAYS + 2) {
|
|
133
|
+
const oldest = dayKey(Date.now() - BALANCE_DAYS * DAY_MS);
|
|
134
|
+
for (const d of [...buckets.keys()]) if (d < oldest) buckets.delete(d);
|
|
135
|
+
}
|
|
136
|
+
if (usage.size > WORKSPACE_CAP) {
|
|
137
|
+
const first = usage.keys().next().value;
|
|
138
|
+
usage.delete(first);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Count one tool call toward the mem/esr balance (no-op for other tools). */
|
|
143
|
+
function record(name, workspace) {
|
|
144
|
+
if (!workspace) return;
|
|
145
|
+
if (MEM_TOOLS.includes(name)) bump(workspace, "mem");
|
|
146
|
+
else if (ESR_TOOLS.includes(name)) bump(workspace, "esr");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** tools/result listener. Fire-and-forget; never throws. */
|
|
150
|
+
function handle(exec, result) {
|
|
151
|
+
// eslint-disable-next-line no-unused-vars
|
|
152
|
+
void result;
|
|
153
|
+
try {
|
|
154
|
+
const name = String(exec?.name ?? "").toLowerCase();
|
|
155
|
+
const agent = exec?.agent;
|
|
156
|
+
const sessionId = agent?.session?.id;
|
|
157
|
+
const cwd = agent?.session?.header?.cwd;
|
|
158
|
+
if (!sessionId || typeof cwd !== "string" || cwd.length === 0) return;
|
|
159
|
+
const workspace = wk(cwd);
|
|
160
|
+
record(name, workspace);
|
|
161
|
+
// P4 conversion: an esr_* call inside the window converts this
|
|
162
|
+
// workspace's most recent pending hint.
|
|
163
|
+
if (ESR_TOOLS.includes(name)) attributeConversion(workspace, Date.now());
|
|
164
|
+
if (name !== "todo_write") return;
|
|
165
|
+
// Snapshot this session's pending plan. When a plan that had pending
|
|
166
|
+
// todos transitioned to a fully-completed list, that is a natural
|
|
167
|
+
// "work finished" event → enable the closure hint (P3).
|
|
168
|
+
const names = pendingTodos(exec?.arguments);
|
|
169
|
+
const prev = todos.get(workspace);
|
|
170
|
+
const prevCount = prev !== void 0 && prev.sessionId === sessionId ? prev.count : -1;
|
|
171
|
+
todos.set(workspace, { sessionId, count: names.length, names, updatedAt: Date.now() });
|
|
172
|
+
if (prevCount > 0 && names.length === 0) {
|
|
173
|
+
workDone.add(`${workspace}::${sessionId}`);
|
|
174
|
+
}
|
|
175
|
+
if (todos.size > WORKSPACE_CAP) {
|
|
176
|
+
const first = todos.keys().next().value;
|
|
177
|
+
todos.delete(first);
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// fully contained — a trigger bug must never disturb the tool pipeline
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Live mem-vs-esr balance for a workspace (last BALANCE_DAYS).
|
|
186
|
+
* Returns { memCalls, esrCalls, days }; all zeros when no data yet.
|
|
187
|
+
*/
|
|
188
|
+
function recentBalance(workspace) {
|
|
189
|
+
const buckets = usage.get(workspace);
|
|
190
|
+
if (buckets === void 0) return { memCalls: 0, esrCalls: 0, days: 0 };
|
|
191
|
+
const oldest = dayKey(Date.now() - BALANCE_DAYS * DAY_MS);
|
|
192
|
+
let memCalls = 0;
|
|
193
|
+
let esrCalls = 0;
|
|
194
|
+
let days = 0;
|
|
195
|
+
for (const [day, c] of buckets) {
|
|
196
|
+
if (day < oldest) continue;
|
|
197
|
+
days += 1;
|
|
198
|
+
memCalls += c.mem;
|
|
199
|
+
esrCalls += c.esr;
|
|
200
|
+
}
|
|
201
|
+
return { memCalls, esrCalls, days };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── P4 conversion measurement ────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** Count a per-(workspace, kind) bucket. */
|
|
207
|
+
function bumpCount(map, workspace, kind, delta) {
|
|
208
|
+
let perWs = map.get(workspace);
|
|
209
|
+
if (perWs === void 0) {
|
|
210
|
+
perWs = new Map();
|
|
211
|
+
map.set(workspace, perWs);
|
|
212
|
+
}
|
|
213
|
+
perWs.set(kind, (perWs.get(kind) ?? 0) + delta);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Merge per-workspace buckets; when ws is given, read only that one. */
|
|
217
|
+
function countsFor(map, workspace) {
|
|
218
|
+
if (workspace !== void 0) return map.get(workspace) ?? new Map();
|
|
219
|
+
const out = new Map();
|
|
220
|
+
for (const perWs of map.values()) {
|
|
221
|
+
for (const [k, n] of perWs) out.set(k, (out.get(k) ?? 0) + n);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Drop hints past the conversion window (or when already attributed). */
|
|
227
|
+
function prunePending() {
|
|
228
|
+
const cutoff = Date.now() - CONVERSION_WINDOW_MS;
|
|
229
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
230
|
+
if (pending[i].shownAt < cutoff) pending.splice(i, 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Record that a hint line was actually injected for a session (P4). Each
|
|
236
|
+
* (workspace, session, kind) counts at most once, matching the once-per-
|
|
237
|
+
* session hint semantics — repeated renders of the same hint do not inflate
|
|
238
|
+
* the exposure count.
|
|
239
|
+
*/
|
|
240
|
+
function emitHint(kind, workspace, sessionId) {
|
|
241
|
+
if (!kind || !HINT_TAGS[kind] || !workspace || !sessionId) return;
|
|
242
|
+
const key = `${workspace}::${sessionId}::${kind}`;
|
|
243
|
+
if (hintSeen.has(key)) return;
|
|
244
|
+
hintSeen.add(key);
|
|
245
|
+
bumpCount(shownCounts, workspace, kind, 1);
|
|
246
|
+
pending.push({ kind, ws: workspace, session: sessionId, shownAt: Date.now() });
|
|
247
|
+
prunePending();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Attribute the most recent pending hint of this workspace (inside the
|
|
252
|
+
* window) to an esr_* tool call. One call converts one hint — attribution
|
|
253
|
+
* stays honest, the rest expire on their own.
|
|
254
|
+
*/
|
|
255
|
+
function attributeConversion(workspace, at) {
|
|
256
|
+
let best = -1;
|
|
257
|
+
for (let i = 0; i < pending.length; i++) {
|
|
258
|
+
const h = pending[i];
|
|
259
|
+
if (h.ws !== workspace) continue;
|
|
260
|
+
if (at - h.shownAt > CONVERSION_WINDOW_MS) continue;
|
|
261
|
+
if (best === -1 || h.shownAt > pending[best].shownAt) best = i;
|
|
262
|
+
}
|
|
263
|
+
if (best === -1) return;
|
|
264
|
+
const h = pending[best];
|
|
265
|
+
pending.splice(best, 1);
|
|
266
|
+
bumpCount(convertedCounts, workspace, h.kind, 1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Hint → esr_* action conversion (P4). Returns a plain, serializable object:
|
|
271
|
+
* { windowMs, total: {shown, converted, rate}, byKind: { <kind>: {shown, converted, rate} } }
|
|
272
|
+
* When `workspace` is omitted, aggregates across all workspaces.
|
|
273
|
+
*/
|
|
274
|
+
function conversionStats(workspace) {
|
|
275
|
+
prunePending();
|
|
276
|
+
const shown = countsFor(shownCounts, workspace);
|
|
277
|
+
const converted = countsFor(convertedCounts, workspace);
|
|
278
|
+
const byKind = {};
|
|
279
|
+
let shownTotal = 0;
|
|
280
|
+
let convertedTotal = 0;
|
|
281
|
+
for (const kind of Object.keys(HINT_TAGS)) {
|
|
282
|
+
const sh = shown.get(kind) ?? 0;
|
|
283
|
+
const cv = converted.get(kind) ?? 0;
|
|
284
|
+
shownTotal += sh;
|
|
285
|
+
convertedTotal += cv;
|
|
286
|
+
byKind[kind] = { shown: sh, converted: cv, rate: sh > 0 ? +(cv / sh).toFixed(4) : null };
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
windowMs: CONVERSION_WINDOW_MS,
|
|
290
|
+
total: {
|
|
291
|
+
shown: shownTotal,
|
|
292
|
+
converted: convertedTotal,
|
|
293
|
+
rate: shownTotal > 0 ? +(convertedTotal / shownTotal).toFixed(4) : null,
|
|
294
|
+
},
|
|
295
|
+
byKind,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* One-shot "promote your plan to an ESR task" hint for the live session.
|
|
301
|
+
* Returns "" unless: this session wrote ≥ minTodosForPromote pending todos,
|
|
302
|
+
* the funnel is narrow (pending > active * todoPromoteRatio), and the hint
|
|
303
|
+
* has not already been emitted this session. Marks the hint emitted on
|
|
304
|
+
* success so it appears at most once per session (no nagging).
|
|
305
|
+
*/
|
|
306
|
+
function promoteHint(sessionId, workspace, activeTasks) {
|
|
307
|
+
if (!sessionId || !workspace) return "";
|
|
308
|
+
const snap = todos.get(workspace);
|
|
309
|
+
if (snap === void 0 || snap.sessionId !== sessionId) return "";
|
|
310
|
+
if (snap.count < minTodosForPromote) return "";
|
|
311
|
+
if (snap.count <= activeTasks * todoPromoteRatio) return "";
|
|
312
|
+
const key = `${workspace}::${sessionId}`;
|
|
313
|
+
if (promoted.has(key)) return "";
|
|
314
|
+
promoted.add(key);
|
|
315
|
+
const sample = snap.names
|
|
316
|
+
.slice(0, 2)
|
|
317
|
+
.map((n) => truncate(String(n).replace(/\s*\n+\s*/g, " ").trim(), 36))
|
|
318
|
+
.join(" / ");
|
|
319
|
+
return `promote: ${snap.count} pending todo(s) vs ${activeTasks} ESR task(s) — multi-step work earns a cross-session task now: esr_task(name="${sample}") ${HINT_TAGS.promote}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* One-shot "root-cause a recurring failure" hint (P0-B). Candidates come
|
|
324
|
+
* from the store (error memories that earned `hits >= minErrorHits` through
|
|
325
|
+
* repeat-failure revival) — the recorder only dedupes which error ids have
|
|
326
|
+
* been surfaced per session. Returns the first not-yet-shown candidate as a
|
|
327
|
+
* line, or "".
|
|
328
|
+
*/
|
|
329
|
+
function errorHint(sessionId, workspace, candidates) {
|
|
330
|
+
if (!sessionId || !workspace) return "";
|
|
331
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return "";
|
|
332
|
+
for (const c of candidates) {
|
|
333
|
+
const key = `${workspace}::${sessionId}::${c.id}`;
|
|
334
|
+
if (errorSeen.has(key)) continue;
|
|
335
|
+
errorSeen.add(key);
|
|
336
|
+
const text = truncate(String(c.text).replace(/\s*\n+\s*/g, " ").trim(), 48);
|
|
337
|
+
return `root-cause: ${text} ×${c.hits ?? 1} — esr_task it so the pattern dies ${HINT_TAGS.rootcause}`;
|
|
338
|
+
}
|
|
339
|
+
return "";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* One-shot "work is finished — close the READY tasks" hint (P3). Fires only
|
|
344
|
+
* after this session's plan went pending → 0 (recorded from todo_write),
|
|
345
|
+
* only when at least one active task has all evidence, and at most once per
|
|
346
|
+
* session.
|
|
347
|
+
*/
|
|
348
|
+
function closureHint(sessionId, workspace, readyTasks) {
|
|
349
|
+
if (!sessionId || !workspace) return "";
|
|
350
|
+
if (!Array.isArray(readyTasks) || readyTasks.length === 0) return "";
|
|
351
|
+
const key = `${workspace}::${sessionId}`;
|
|
352
|
+
if (!workDone.has(key)) return "";
|
|
353
|
+
if (closureSeen.has(key)) return "";
|
|
354
|
+
closureSeen.add(key);
|
|
355
|
+
const t = readyTasks[0];
|
|
356
|
+
return `close: task ${t.id.slice(0, 6)} · ${truncate(t.name, 40)} — evidence complete, esr_close now (artifact/evaluation/memory_ref) ${HINT_TAGS.close}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* One-shot "this task has gone stale" hint (P3). Candidates are active tasks
|
|
361
|
+
* with no update for `staleTaskDays`. Each task id is surfaced at most once
|
|
362
|
+
* per session (one line per render).
|
|
363
|
+
*/
|
|
364
|
+
function staleHint(sessionId, workspace, staleTasks) {
|
|
365
|
+
if (!sessionId || !workspace) return "";
|
|
366
|
+
if (!Array.isArray(staleTasks) || staleTasks.length === 0) return "";
|
|
367
|
+
for (const t of staleTasks) {
|
|
368
|
+
const key = `${workspace}::${sessionId}::${t.id}`;
|
|
369
|
+
if (staleSeen.has(key)) continue;
|
|
370
|
+
staleSeen.add(key);
|
|
371
|
+
const at = t.updatedAt ?? t.createdAt ?? Date.now();
|
|
372
|
+
const days = Math.max(1, Math.round((Date.now() - at) / DAY_MS));
|
|
373
|
+
return `stale: ${t.id.slice(0, 6)} · ${truncate(t.name, 40)} — no update ${days}d, esr_close with evidence or drop the intent ${HINT_TAGS.stale}`;
|
|
374
|
+
}
|
|
375
|
+
return "";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { handle, record, recentBalance, promoteHint, errorHint, closureHint, staleHint, emitHint, conversionStats };
|
|
379
|
+
}
|