dsh-decision-layer 0.7.1 → 0.7.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.
Files changed (57) hide show
  1. package/README.en.md +8 -8
  2. package/README.md +10 -10
  3. package/lib/client.js +39 -14
  4. package/lib/host/backend.d.ts +15 -0
  5. package/lib/host/backend.js +149 -0
  6. package/lib/host/config-store.d.ts +12 -0
  7. package/lib/host/config-store.js +254 -0
  8. package/lib/host/danger-gate.d.ts +45 -0
  9. package/lib/host/danger-gate.js +196 -0
  10. package/lib/host/excerpt.d.ts +25 -0
  11. package/lib/host/excerpt.js +62 -0
  12. package/lib/host/index.d.ts +6 -0
  13. package/lib/host/index.js +290 -0
  14. package/lib/host/log-analyze.d.ts +121 -0
  15. package/lib/host/log-analyze.js +254 -0
  16. package/lib/host/log-read.d.ts +4 -0
  17. package/lib/host/log-read.js +34 -0
  18. package/lib/host/log-store.d.ts +15 -0
  19. package/lib/host/log-store.js +98 -0
  20. package/lib/host/loop-guard.d.ts +10 -0
  21. package/lib/host/loop-guard.js +52 -0
  22. package/lib/host/manager-api.d.ts +20 -0
  23. package/lib/host/manager-api.js +174 -0
  24. package/lib/host/narrowing.d.ts +48 -0
  25. package/lib/host/narrowing.js +213 -0
  26. package/lib/host/quality-trend.d.ts +14 -0
  27. package/lib/host/quality-trend.js +22 -0
  28. package/lib/host/self-check.d.ts +27 -0
  29. package/lib/host/self-check.js +104 -0
  30. package/lib/host/session-state.d.ts +19 -0
  31. package/lib/host/session-state.js +288 -0
  32. package/lib/host/state-store.d.ts +14 -0
  33. package/lib/host/state-store.js +109 -0
  34. package/lib/host/steer-once.d.ts +13 -0
  35. package/lib/host/steer-once.js +25 -0
  36. package/lib/host/task-completion.d.ts +29 -0
  37. package/lib/host/task-completion.js +148 -0
  38. package/lib/host/types.d.ts +343 -0
  39. package/lib/host/types.js +11 -0
  40. package/package.json +17 -10
  41. package/service/.gitkeep +0 -0
  42. package/service/backend.mjs +0 -127
  43. package/service/config-store.mjs +0 -311
  44. package/service/danger-gate.mjs +0 -182
  45. package/service/dsh.mjs +0 -288
  46. package/service/log-analyze.mjs +0 -257
  47. package/service/log-read.mjs +0 -27
  48. package/service/log-store.mjs +0 -82
  49. package/service/loop-guard.mjs +0 -51
  50. package/service/manager-api.mjs +0 -137
  51. package/service/narrowing.mjs +0 -226
  52. package/service/quality-trend.mjs +0 -29
  53. package/service/self-check.mjs +0 -131
  54. package/service/session-state.mjs +0 -260
  55. package/service/state-store.mjs +0 -100
  56. package/service/steer-once.mjs +0 -25
  57. package/service/task-completion.mjs +0 -163
package/service/dsh.mjs DELETED
@@ -1,288 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
3
- import { resolveConfig, featureEnabled } from './config-store.mjs';
4
- import { createBackend } from './backend.mjs';
5
- import { createManagerRoutes } from './manager-api.mjs';
6
- import { createSessionState } from './session-state.mjs';
7
- import { createDangerGate, normalizeDangerRules } from './danger-gate.mjs';
8
- import { createSelfCheck } from './self-check.mjs';
9
- import { createNarrowing } from './narrowing.mjs';
10
- import { createTaskCompletion } from './task-completion.mjs';
11
- import { createLoopGuard } from './loop-guard.mjs';
12
- import { createLogStore } from './log-store.mjs';
13
- import { createStateStore } from './state-store.mjs';
14
- import { analyzeSessionLogs, reconstructSessionSeed, listSessionDecisions } from './log-analyze.mjs';
15
-
16
- export const name = 'dsh-decision-layer';
17
- export const inject = ['tools', 'skills'];
18
-
19
- // Best-effort extraction of the turn's final assistant text. The self-check
20
- // treats empty output as "skip", so an unreachable shape degrades safely.
21
- function lastAssistantText(agent) {
22
- try {
23
- const messages = agent?.session?.deriveMessages?.();
24
- if (!Array.isArray(messages)) return '';
25
- for (let i = messages.length - 1; i >= 0; i--) {
26
- const message = messages[i];
27
- if (message?.role !== 'assistant' || !Array.isArray(message.content)) continue;
28
- return message.content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('');
29
- }
30
- return '';
31
- } catch { return ''; }
32
- }
33
-
34
- // The task text a narrowing pass judges tools against: the user messages
35
- // entering this step. An unreachable shape yields '', which skips narrowing.
36
- function stepTaskText(messages) {
37
- try {
38
- if (!Array.isArray(messages)) return '';
39
- return messages
40
- .filter(message => message?.role === 'user' && Array.isArray(message.content))
41
- .flatMap(message => message.content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text))
42
- .join('\n').slice(0, 8 * 1024);
43
- } catch { return ''; }
44
- }
45
-
46
- // The user's request text for this turn, used by task-completion to extract the
47
- // explicit conditions. Best-effort: an unreachable shape yields '', which skips
48
- // the check. Takes the trailing run of user messages (the prompt entering the turn).
49
- function turnUserText(agent) {
50
- try {
51
- const messages = agent?.session?.deriveMessages?.();
52
- if (!Array.isArray(messages)) return '';
53
- const texts = [];
54
- let collecting = false;
55
- // Walk backwards: skip the turn's trailing assistant/tool messages, collect the
56
- // contiguous user run that started this turn, then stop at the prior assistant.
57
- for (let i = messages.length - 1; i >= 0; i--) {
58
- const message = messages[i];
59
- if (message?.role === 'user' && Array.isArray(message.content)) {
60
- collecting = true;
61
- texts.unshift(message.content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n'));
62
- } else if (collecting) break;
63
- }
64
- return texts.join('\n').slice(0, 8 * 1024);
65
- } catch { return ''; }
66
- }
67
-
68
- // Tool evidence for the turn's task-completion check: the tool calls and their
69
- // ok/error outcome from the derived history. Names only (no arguments), bounded.
70
- // An unreachable shape yields [], which skips the check.
71
- function turnToolEvidence(agent) {
72
- try {
73
- const messages = agent?.session?.deriveMessages?.();
74
- if (!Array.isArray(messages)) return [];
75
- const evidence = [];
76
- for (const message of messages) {
77
- if (message?.role !== 'tool' || !Array.isArray(message.content)) continue;
78
- const tool = typeof message.name === 'string' ? message.name : (typeof message.toolName === 'string' ? message.toolName : '');
79
- if (!tool) continue;
80
- evidence.push({ tool: tool.slice(0, 128), outcome: message.isError === true ? 'error' : 'ok' });
81
- if (evidence.length >= 40) break;
82
- }
83
- return evidence;
84
- } catch { return []; }
85
- }
86
-
87
- // Enumerate the tool names/descriptions visible to this agent scope, best-effort.
88
- function agentTools(ctx, agent) {
89
- try {
90
- const schemas = typeof ctx.tools?.schemas === 'function' ? ctx.tools.schemas(agent) : undefined;
91
- if (!Array.isArray(schemas)) return [];
92
- return schemas
93
- .filter(schema => schema && typeof schema.name === 'string')
94
- .map(schema => ({ name: schema.name, description: typeof schema.description === 'string' ? schema.description : '' }));
95
- } catch { return []; }
96
- }
97
-
98
- export async function apply(ctx, options = {}) {
99
- const path = options.configPath;
100
- const backend = createBackend({ config: () => resolveConfig({ path }) });
101
- // The research log sink is attached once the web server reveals the instance
102
- // port (used in the file name); until then decision logs stay in memory only.
103
- let logStore;
104
- // Durable per-session mirror of the live decision counters/log, keyed by
105
- // session id (not port), so a resumed session's panel survives an instance
106
- // restart instead of resetting to zero. Independent of the JSONL research log.
107
- const stateStore = createStateStore();
108
- const sessions = createSessionState({
109
- onLog: entry => logStore?.append(entry),
110
- // Restore a resumed session from its durable snapshot; if it has none yet but
111
- // has persisted JSONL decision history (e.g. a session that predates this
112
- // persistence, or whose snapshot was pruned), reconstruct a seed from the
113
- // logs instead. Logs are read across all ports and filtered by the globally
114
- // unique session id, so a changed instance port never orphans the history.
115
- loadState: async id => (await stateStore.load(id)) ?? reconstructSessionSeed(id, {
116
- trendConfig: (await resolveConfig({ path })).checkSettings,
117
- }).catch(() => undefined),
118
- persistState: (id, state) => stateStore.save(id, state),
119
- });
120
- if (typeof ctx.effect === 'function') ctx.effect(() => () => { void stateStore.dispose(); });
121
- const gate = createDangerGate({
122
- evaluate: (input, request) => backend.evaluate(input, request),
123
- sessions,
124
- rules: async () => normalizeDangerRules((await resolveConfig({ path })).dangerRules),
125
- // Live config-backed floor (default 0.6): a `deny` only blocks once the model
126
- // is at least this confident. Read per call so an edit takes effect at once.
127
- minConfidence: async () => (await resolveConfig({ path })).gateSettings?.minConfidence,
128
- });
129
- const loopGuard = createLoopGuard({ sessions });
130
- // Live per-feature enablement; every decision point defaults to enabled.
131
- // An invalid config must not fail every tool call: these hooks run inside the
132
- // host's tools/pre-execute waterfall, which converts a throw into a failed call
133
- // (the tool never dispatches, so `read`/`edit` can no longer repair the file).
134
- // An unreadable config therefore degrades to "all features on", and each
135
- // decision point keeps its own safe fallback (the gate falls back to 0.6 and
136
- // the built-in rules), so the safety net stays armed instead of the agent
137
- // losing every tool.
138
- const featureOn = async key => {
139
- try { return featureEnabled((await resolveConfig({ path })).features, key); }
140
- catch { return true; }
141
- };
142
- if (typeof ctx.on === 'function') {
143
- if (typeof ctx.tools.guard === 'function') {
144
- // Deterministic loop guard runs with no backend; then the dangerous-gate
145
- // monotonic denial. Guards can only deny, never re-allow.
146
- ctx.tools.guard(exec => loopGuard.check(exec));
147
- ctx.tools.guard(exec => gate.guardReason(exec));
148
- }
149
- const install = () => {
150
- const disposePre = ctx.on('tools/pre-execute', async (exec, next) => {
151
- if (!(await featureOn('gate'))) return next();
152
- return gate.preExecute(exec, next);
153
- });
154
- const disposePost = ctx.on('tools/post-execute', async (exec, result, next) => {
155
- gate.observeResult(exec, result);
156
- return next();
157
- });
158
- return () => { disposePre?.(); disposePost?.(); };
159
- };
160
- if (typeof ctx.effect === 'function') ctx.effect(install);
161
- else install();
162
-
163
- const selfCheck = createSelfCheck({
164
- evaluate: (input, request) => backend.evaluate(input, request),
165
- sessions,
166
- settings: async () => (await resolveConfig({ path })).checkSettings,
167
- steer: (agent, text) => agent.steer(createUserMessage({
168
- content: [{ type: 'text', text }],
169
- source: { kind: 'user' },
170
- })),
171
- });
172
- const installCheck = () => ctx.on('agent/turn-stopping', async payload => {
173
- if (!(await featureOn('check'))) return;
174
- return selfCheck.review({
175
- agent: payload.agent, turn: payload.turn, signal: payload.signal,
176
- output: lastAssistantText(payload.agent),
177
- userText: turnUserText(payload.agent),
178
- });
179
- });
180
- if (typeof ctx.effect === 'function') ctx.effect(installCheck);
181
- else installCheck();
182
-
183
- const taskCompletion = createTaskCompletion({
184
- evaluate: (input, request) => backend.evaluate(input, request),
185
- sessions,
186
- settings: async () => (await resolveConfig({ path })).completeSettings,
187
- steer: (agent, text) => agent.steer(createUserMessage({
188
- content: [{ type: 'text', text }],
189
- source: { kind: 'user' },
190
- })),
191
- });
192
- // Independent turn-stopping listener, separate from the self-check: it checks
193
- // whether the user's explicit conditions have supporting tool evidence.
194
- const installCompletion = () => ctx.on('agent/turn-stopping', async payload => {
195
- if (!(await featureOn('complete'))) return;
196
- return taskCompletion.review({
197
- agent: payload.agent, turn: payload.turn, signal: payload.signal,
198
- output: lastAssistantText(payload.agent),
199
- userText: turnUserText(payload.agent),
200
- evidence: turnToolEvidence(payload.agent),
201
- });
202
- });
203
- if (typeof ctx.effect === 'function') ctx.effect(installCompletion);
204
- else installCompletion();
205
-
206
- const narrowing = createNarrowing({
207
- evaluate: (input, request) => backend.evaluate(input, request),
208
- sessions,
209
- settings: async () => (await resolveConfig({ path })).narrowSettings,
210
- });
211
- // Narrow once per turn, on its first step, and only enforce a restriction in
212
- // enforce mode. The restriction is agent-scoped and lifted before the next
213
- // turn's pass so a later turn is judged against the full host-allowed set.
214
- const narrowedTurns = new Map();
215
- const installNarrowingHook = () => ctx.on('agent/pre-step', async (payload, next) => {
216
- const agent = payload?.agent;
217
- const id = agent?.session?.id;
218
- const turn = payload?.turn;
219
- // Rehydrate this session's persisted counters/log on its first step this
220
- // process, before any gate/check/narrow/complete event mutates them, so a
221
- // resumed session continues from its saved totals instead of from zero.
222
- if (typeof id === 'string') await sessions.ensureLoaded(id);
223
- if (typeof id === 'string' && Number.isInteger(turn)) {
224
- const state = narrowedTurns.get(id);
225
- if (!state || state.turn !== turn) {
226
- // Release the previous turn's restriction first: restrict is agent-scoped
227
- // and stays in force until disposed, so a turn where narrowing is now off
228
- // must not inherit the earlier allow-list.
229
- state?.dispose?.();
230
- narrowedTurns.delete(id);
231
- if (await featureOn('narrow')) {
232
- const entry = { turn, dispose: undefined };
233
- narrowedTurns.set(id, entry);
234
- if (narrowedTurns.size > 1024) { const oldest = narrowedTurns.keys().next().value; if (oldest !== id) { narrowedTurns.get(oldest)?.dispose?.(); narrowedTurns.delete(oldest); } }
235
- try {
236
- const result = await narrowing.review({
237
- agent, turn, signal: payload.signal,
238
- state: stepTaskText(payload.messages), tools: agentTools(ctx, agent),
239
- });
240
- if (result.mode === 'enforce' && result.applied && typeof agent?.ctx?.tools?.restrict === 'function') {
241
- entry.dispose = agent.ctx.tools.restrict({ allow: result.keep });
242
- }
243
- } catch { /* narrowing is best effort; never block the step */ }
244
- }
245
- }
246
- }
247
- return next();
248
- });
249
- if (typeof ctx.effect === 'function') ctx.effect(installNarrowingHook);
250
- else installNarrowingHook();
251
- }
252
- const rawSkill = await readFile(new URL('../skills/decision-layer/SKILL.md', import.meta.url), 'utf8');
253
- const content = rawSkill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim();
254
- ctx.skills.register({ name: 'decision-layer', description: '显式发起结构化裁决,辅助明确边界的选择与评分。', content, source: 'bundled' });
255
-
256
- const output = { schema: { type: 'object', additionalProperties: true }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] };
257
- ctx.tools.register({
258
- name: 'decision_evaluate', description: '对 state 和 questions 执行一次显式结构化裁决,不修改 agent 行为。',
259
- parameters: { type: 'object', required: ['state', 'questions'], properties: {
260
- state: { oneOf: [{ type: 'string' }, { type: 'object', additionalProperties: true }, { type: 'array', items: {} }] },
261
- questions: { type: 'object', additionalProperties: { type: 'object', additionalProperties: true } },
262
- }, additionalProperties: false },
263
- output, timeoutMs: 25_000,
264
- execute: (args, exec) => backend.evaluate(args, { signal: exec.signal }),
265
- });
266
- ctx.tools.register({
267
- name: 'decision_check_connection', description: '向裁决端点发送一次轻量连通测试,不增加自动裁决指标。',
268
- parameters: { type: 'object', properties: {}, additionalProperties: false }, output, timeoutMs: 25_000,
269
- execute: async (_args, exec) => {
270
- const result = await backend.evaluate({ state: 'Connection test', questions: {
271
- connected: { type: 'noul', instructions: 'Is this a connection test?' },
272
- } }, { signal: exec.signal });
273
- return { connected: true, model: result.model, usage: result.usage };
274
- },
275
- });
276
-
277
- ctx.inject(['webServer'], web => {
278
- logStore = createLogStore({ port: web.webServer.port });
279
- const port = web.webServer.port;
280
- const analyze = async sessionId => analyzeSessionLogs(sessionId, {
281
- port,
282
- trendConfig: (await resolveConfig({ path })).checkSettings,
283
- });
284
- const listRows = (sessionId, { page, pageSize } = {}) => listSessionDecisions(sessionId, { port, page, pageSize });
285
- const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
286
- return () => { unregister.forEach(dispose => dispose()); const store = logStore; logStore = undefined; void store?.dispose(); };
287
- });
288
- }
@@ -1,257 +0,0 @@
1
- import { createReadableStream } from './log-read.mjs';
2
- import { advanceRun, severityOf } from './quality-trend.mjs';
3
- import { narrowApplied } from './narrowing.mjs';
4
-
5
- // Offline analysis over the persisted decision JSONL logs. Pure aggregation:
6
- // given a stream of records (decision events + annotation events) for one
7
- // session, fold annotations onto decisions by id (latest wins) and build a
8
- // process-metrics profile plus a quality-trend threshold backtest.
9
- //
10
- // Reading is separated (log-read.mjs) so this module stays pure and testable
11
- // without touching the filesystem.
12
-
13
- const DECISION_KINDS = new Set(['gate', 'check', 'narrow', 'complete']);
14
- const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
15
-
16
- // Reduce one session's records into decisions (with the latest annotation
17
- // attached) and per-kind aggregates. `records` is any iterable of parsed rows.
18
- export function foldSession(records) {
19
- const decisions = new Map(); // id → decision record
20
- const annotations = new Map(); // target id → latest annotation
21
- for (const record of records) {
22
- if (!isObject(record)) continue;
23
- if (record.kind === 'annotation') {
24
- const target = typeof record.target === 'string' ? record.target : undefined;
25
- if (!target) continue;
26
- const prev = annotations.get(target);
27
- if (!prev || (Number.isFinite(record.at) && record.at >= (prev.at ?? 0))) {
28
- annotations.set(target, { rating: record.rating, at: Number.isFinite(record.at) ? record.at : 0 });
29
- }
30
- continue;
31
- }
32
- if (DECISION_KINDS.has(record.kind) && typeof record.id === 'string') {
33
- decisions.set(record.id, record);
34
- }
35
- }
36
- return { decisions, annotations };
37
- }
38
-
39
- // A confidence bucket label for the [0,1] range, coarse enough to be useful.
40
- function confidenceBucket(value) {
41
- if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
42
- if (value < 0.4) return 'lt40';
43
- if (value < 0.6) return 'lt60';
44
- if (value < 0.8) return 'lt80';
45
- return 'gte80';
46
- }
47
-
48
- function emptyProfile() {
49
- return {
50
- gate: { attempts: 0, allow: 0, ask: 0, deny: 0, error: 0 },
51
- check: { attempts: 0, ok: 0, low: 0, error: 0, scoreSum: 0, scoreCount: 0, confidence: { lt40: 0, lt60: 0, lt80: 0, gte80: 0 } },
52
- narrow: { attempts: 0, applied: 0, error: 0, droppedSum: 0, tooManyCandidates: 0 },
53
- complete: { attempts: 0, satisfied: 0, unsatisfied: 0, insufficient: 0, error: 0, steered: 0 },
54
- };
55
- }
56
-
57
- // Build the process-metrics profile from the folded decisions. Clean denominators:
58
- // each `attempts` counts real evaluations of that kind; averages (e.g. mean
59
- // dropped) are reported as-is, never mislabeled as percentages.
60
- export function buildProfile(decisions) {
61
- const profile = emptyProfile();
62
- for (const record of decisions.values()) {
63
- const kind = record.kind;
64
- const bucket = profile[kind];
65
- if (!bucket) continue;
66
- bucket.attempts++;
67
- if (kind === 'gate') {
68
- if (record.outcome === 'error') bucket.error++;
69
- else if (['allow', 'ask', 'deny'].includes(record.outcome)) bucket[record.outcome]++;
70
- } else if (kind === 'check') {
71
- if (record.outcome === 'error') bucket.error++;
72
- else if (record.outcome === 'low') bucket.low++;
73
- else bucket.ok++;
74
- if (typeof record.score === 'number' && Number.isFinite(record.score)) { bucket.scoreSum += record.score; bucket.scoreCount++; }
75
- const cb = confidenceBucket(record.confidence);
76
- if (cb) bucket.confidence[cb]++;
77
- } else if (kind === 'narrow') {
78
- if (record.outcome === 'error') bucket.error++;
79
- else if (narrowApplied(record)) bucket.applied++;
80
- if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.droppedSum += record.dropped;
81
- if (record.reason === 'too-many-candidates') bucket.tooManyCandidates++;
82
- } else if (kind === 'complete') {
83
- if (record.outcome === 'error') bucket.error++;
84
- if (Number.isSafeInteger(record.satisfied)) bucket.satisfied += record.satisfied;
85
- if (Number.isSafeInteger(record.unsatisfied)) bucket.unsatisfied += record.unsatisfied;
86
- if (Number.isSafeInteger(record.insufficient)) bucket.insufficient += record.insufficient;
87
- if (record.steered === true) bucket.steered++;
88
- }
89
- }
90
- return profile;
91
- }
92
-
93
- // Replay the quality-trend thresholds over this session's check records in time
94
- // order: how many times would the alarm have fired at warn / severe? Uses the
95
- // same run semantics as quality-trend.mjs (good resets, high-confidence low
96
- // grows, low-confidence low and non-low are skipped for the run — but a good
97
- // score still resets). This is a calibration aid, not a live signal.
98
- export function backtestTrend(checkRecords, { trendMinConfidence = 0.6, trendRun = 3, trendSevereRun = 5, lowScoreThreshold = 1 } = {}) {
99
- const ordered = [...checkRecords].filter(r => isObject(r) && r.kind === 'check').sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
100
- let run = 0, warnHits = 0, severeHits = 0, maxRun = 0;
101
- for (const record of ordered) {
102
- if (record.outcome === 'error') continue; // unevaluated: skip
103
- const isLow = record.outcome === 'low' || (typeof record.score === 'number' && record.score <= lowScoreThreshold);
104
- if (!isLow) { run = 0; continue; }
105
- const confident = typeof record.confidence === 'number' && Number.isFinite(record.confidence) && record.confidence >= trendMinConfidence;
106
- if (!confident) continue; // low-confidence low: skip, run unchanged
107
- run++;
108
- maxRun = Math.max(maxRun, run);
109
- if (run === trendSevereRun) severeHits++;
110
- else if (run === trendRun) warnHits++;
111
- }
112
- return { warnHits, severeHits, maxRun };
113
- }
114
-
115
- // Attach annotation summary: how many decisions carry a good/bad/unsure rating.
116
- // Also returns a per-decision ratings map (id → rating) so a UI can show the
117
- // persisted annotation state on each decision, whichever surface renders it.
118
- export function annotationSummary(decisions, annotations) {
119
- const summary = { rated: 0, good: 0, bad: 0, unsure: 0 };
120
- const ratings = Object.create(null);
121
- for (const [id] of decisions) {
122
- const annotation = annotations.get(id);
123
- if (!annotation || typeof annotation.rating !== 'string') continue;
124
- summary.rated++;
125
- if (annotation.rating === 'good') summary.good++;
126
- else if (annotation.rating === 'bad') summary.bad++;
127
- else if (annotation.rating === 'unsure') summary.unsure++;
128
- ratings[id] = annotation.rating;
129
- }
130
- return { summary, ratings };
131
- }
132
-
133
- // One-call analysis over an iterable of records for a single session.
134
- export function analyzeSession(records, trendConfig) {
135
- const { decisions, annotations } = foldSession(records);
136
- const annotation = annotationSummary(decisions, annotations);
137
- return {
138
- totalDecisions: decisions.size,
139
- profile: buildProfile(decisions),
140
- trendBacktest: backtestTrend([...decisions.values()], trendConfig),
141
- annotations: annotation.summary,
142
- ratings: annotation.ratings,
143
- };
144
- }
145
-
146
- // Read the persisted logs for one session id and analyze them. Streams files so
147
- // a large corpus is never fully buffered; only rows whose sessionId matches are
148
- // kept. `readStream` is injected for tests; production reads the logs dir.
149
- export async function analyzeSessionLogs(sessionId, { dir, port, trendConfig, readStream = createReadableStream } = {}) {
150
- if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
151
- const records = [];
152
- for await (const record of readStream({ dir, port })) {
153
- if (!isObject(record)) continue;
154
- if (record.sessionId === sessionId) records.push(record);
155
- }
156
- return analyzeSession(records, trendConfig);
157
- }
158
-
159
- // Reconstruct a live session-state seed (the counter shape session-state keeps,
160
- // plus the recent decision log) from this session's folded decisions. Used to
161
- // bridge a restart for a session that has persisted JSONL history but no state
162
- // snapshot yet, so its panel resumes from its real totals instead of from zero.
163
- //
164
- // Faithful for every counter because each feature logs exactly one record per
165
- // record*() call with the same outcome — with one documented exception:
166
- // gate.actual (observed tool outcomes) is never logged, so it starts at zero and
167
- // resumes as new tool results arrive. The check trend run/severity is replayed
168
- // with the same advanceRun/severityOf semantics the live path uses.
169
- export function buildSeed(decisions, { maxLog = 50, trendConfig = {} } = {}) {
170
- const ordered = [...decisions.values()].filter(record => DECISION_KINDS.has(record.kind)).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
171
- const seed = {
172
- attempts: 0, failures: 0,
173
- gate: { attempts: 0, failures: 0, ask: 0, deny: 0, allow: 0, actual: { allow: 0, deny: 0, error: 0 } },
174
- check: { attempts: 0, failures: 0, low: 0, run: 0, severity: 'normal' },
175
- narrow: { attempts: 0, failures: 0, applied: 0, dropped: 0 },
176
- complete: { attempts: 0, failures: 0, unsatisfied: 0, satisfied: 0, insufficient: 0, steered: 0 },
177
- log: [],
178
- };
179
- const minConfidence = typeof trendConfig.trendMinConfidence === 'number' ? trendConfig.trendMinConfidence : 0.6;
180
- const thresholds = { trendRun: trendConfig.trendRun ?? 3, trendSevereRun: trendConfig.trendSevereRun ?? 5 };
181
- let run = 0;
182
- for (const record of ordered) {
183
- const bucket = seed[record.kind];
184
- seed.attempts++;
185
- bucket.attempts++;
186
- const isError = record.outcome === 'error';
187
- if (isError) { seed.failures++; bucket.failures++; }
188
- if (record.kind === 'gate') {
189
- if (['allow', 'ask', 'deny'].includes(record.outcome)) bucket[record.outcome]++;
190
- } else if (record.kind === 'check') {
191
- if (!isError) {
192
- if (record.outcome === 'low') bucket.low++;
193
- // Replay the trend run exactly as recordCheck does: lowScore keys off the
194
- // 'low' outcome, confident off the logged confidence vs the threshold.
195
- const confident = typeof record.confidence === 'number' && Number.isFinite(record.confidence) && record.confidence >= minConfidence;
196
- run = advanceRun(run, { lowScore: record.outcome === 'low', confident });
197
- }
198
- } else if (record.kind === 'narrow') {
199
- if (record.outcome === 'applied') {
200
- if (narrowApplied(record)) bucket.applied++;
201
- if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.dropped += record.dropped;
202
- }
203
- } else if (record.kind === 'complete') {
204
- if (!isError) {
205
- if (Number.isSafeInteger(record.satisfied)) bucket.satisfied += record.satisfied;
206
- if (Number.isSafeInteger(record.unsatisfied)) bucket.unsatisfied += record.unsatisfied;
207
- if (Number.isSafeInteger(record.insufficient)) bucket.insufficient += record.insufficient;
208
- if (record.steered === true) bucket.steered++;
209
- }
210
- }
211
- }
212
- seed.check.run = run;
213
- seed.check.severity = severityOf(run, thresholds);
214
- // The recent-log list: the newest decisions, as plain records (drop sessionId,
215
- // which the live log does not carry). Already in the panel-record shape.
216
- seed.log = ordered.slice(-maxLog).map(record => { const { sessionId, ...rest } = record; return rest; });
217
- return seed;
218
- }
219
-
220
- // Read one session's persisted JSONL and reconstruct its live state seed, or
221
- // undefined when it has no decision history. Streams files like analyzeSessionLogs.
222
- export async function reconstructSessionSeed(sessionId, { dir, port, trendConfig, maxLog, readStream = createReadableStream } = {}) {
223
- if (typeof sessionId !== 'string' || !sessionId.trim()) return undefined;
224
- const records = [];
225
- for await (const record of readStream({ dir, port })) {
226
- if (!isObject(record)) continue;
227
- if (record.sessionId === sessionId) records.push(record);
228
- }
229
- if (records.length === 0) return undefined;
230
- const { decisions } = foldSession(records);
231
- if (decisions.size === 0) return undefined;
232
- return buildSeed(decisions, { maxLog, trendConfig });
233
- }
234
-
235
- // Read one session's persisted decision rows and return a single newest-first
236
- // page. Backs the analysis tab's full decision log (the dock panel still shows
237
- // only the in-memory recent rows). Decisions are folded by id (latest wins) so a
238
- // re-logged id is not double-counted. Rows carry only the panel fields, including
239
- // the gate's redacted, length-bounded command/path. The internal sessionId tag is
240
- // stripped before the row leaves the server.
241
- export async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, readStream = createReadableStream } = {}) {
242
- if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
243
- const size = Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= 500 ? pageSize : 100;
244
- const records = [];
245
- for await (const record of readStream({ dir, port })) {
246
- if (!isObject(record)) continue;
247
- if (record.sessionId === sessionId) records.push(record);
248
- }
249
- const { decisions } = foldSession(records);
250
- const rows = [...decisions.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)); // newest first
251
- const total = rows.length;
252
- const pages = Math.max(1, Math.ceil(total / size));
253
- const current = Math.min(Math.max(1, Number.isSafeInteger(page) ? page : 1), pages);
254
- const start = (current - 1) * size;
255
- const entries = rows.slice(start, start + size).map(({ sessionId: _sessionId, ...rest }) => rest);
256
- return { entries, total, page: current, pageSize: size, pages };
257
- }
@@ -1,27 +0,0 @@
1
- import { createReadStream } from 'node:fs';
2
- import { readdir } from 'node:fs/promises';
3
- import { createInterface } from 'node:readline';
4
- import { join } from 'node:path';
5
- import { logsDir } from './log-store.mjs';
6
-
7
- // Stream the persisted decision JSONL logs line by line, yielding one parsed
8
- // record per line. Files are read sequentially so a large corpus is never fully
9
- // buffered. A malformed line is skipped, not fatal. Only this port's files are
10
- // read when `port` is given (matching the log-store naming); otherwise all
11
- // `decisions-*.jsonl` files are read.
12
- export async function* createReadableStream({ dir = logsDir(), port } = {}) {
13
- const prefix = Number.isInteger(port) && port > 0 ? `decisions-${port}-` : 'decisions-';
14
- let names;
15
- try { names = await readdir(dir); } catch { return; }
16
- const files = names.filter(name => name.startsWith(prefix) && name.endsWith('.jsonl')).sort();
17
- for (const name of files) {
18
- const stream = createReadStream(join(dir, name), { encoding: 'utf8' });
19
- const lines = createInterface({ input: stream, crlfDelay: Infinity });
20
- try {
21
- for await (const line of lines) {
22
- if (!line.trim()) continue;
23
- try { yield JSON.parse(line); } catch { /* skip malformed line */ }
24
- }
25
- } finally { lines.close(); stream.close(); }
26
- }
27
- }
@@ -1,82 +0,0 @@
1
- import { mkdir, appendFile, readdir, rm } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- export function logsDir() {
6
- return join(homedir(), '.config', 'dsh-decision-layer', 'logs');
7
- }
8
-
9
- const dayStamp = date => date.toISOString().slice(0, 10);
10
-
11
- // Batched, rolling JSONL log writer keyed by the running instance's port. One
12
- // file per UTC day: `decisions-<port>-YYYY-MM-DD.jsonl`. Entries are buffered and
13
- // flushed on an interval or when the buffer fills; every flush prunes this port's
14
- // files older than the retention window. Writing is best-effort: a failed flush
15
- // drops that batch rather than throwing into the decision path.
16
- export function createLogStore({ port, dir = logsDir(), flushIntervalMs = 5000,
17
- maxBuffer = 200, retentionDays = 30, now = () => new Date() } = {}) {
18
- const safePort = Number.isInteger(port) && port > 0 && port < 65536 ? port : 0;
19
- const prefix = `decisions-${safePort}-`;
20
- let buffer = [];
21
- let timer;
22
- let flushing = false;
23
-
24
- const fileFor = date => join(dir, `${prefix}${dayStamp(date)}.jsonl`);
25
-
26
- const prune = async () => {
27
- let names;
28
- try { names = await readdir(dir); } catch { return; }
29
- const cutoff = now().getTime() - retentionDays * 24 * 60 * 60 * 1000;
30
- await Promise.all(names.map(async name => {
31
- if (!name.startsWith(prefix) || !name.endsWith('.jsonl')) return;
32
- const stamp = name.slice(prefix.length, -'.jsonl'.length);
33
- const day = Date.parse(`${stamp}T00:00:00Z`);
34
- if (Number.isFinite(day) && day < cutoff) await rm(join(dir, name), { force: true }).catch(() => {});
35
- }));
36
- };
37
-
38
- const flush = async () => {
39
- if (flushing || buffer.length === 0) return;
40
- flushing = true;
41
- const pending = buffer;
42
- buffer = [];
43
- try {
44
- await mkdir(dir, { recursive: true, mode: 0o700 });
45
- const byFile = new Map();
46
- for (const entry of pending) {
47
- const at = Number.isFinite(entry?.at) ? entry.at : now().getTime();
48
- const file = fileFor(new Date(at));
49
- (byFile.get(file) ?? byFile.set(file, []).get(file)).push(`${JSON.stringify(entry)}\n`);
50
- }
51
- for (const [file, lines] of byFile) await appendFile(file, lines.join(''), { mode: 0o600 });
52
- await prune();
53
- } catch { /* best effort; a dropped batch must never break the decision path */ }
54
- finally { flushing = false; }
55
- };
56
-
57
- return {
58
- append(entry) {
59
- if (entry === null || typeof entry !== 'object') return;
60
- buffer.push(entry);
61
- if (buffer.length >= maxBuffer) { void flush(); return; }
62
- if (!timer) timer = setTimeout(() => { timer = undefined; void flush(); }, flushIntervalMs);
63
- },
64
- // An annotation is an explicit user action, not telemetry: write it
65
- // immediately (not batched) and surface failure to the caller instead of
66
- // dropping it. It lands in the same JSONL directory as a `kind:"annotation"`
67
- // event and is pruned with the decision logs on the next flush.
68
- async appendAnnotation(entry) {
69
- if (entry === null || typeof entry !== 'object') throw new Error('Invalid annotation');
70
- const at = Number.isFinite(entry.at) ? entry.at : now().getTime();
71
- const record = { ...entry, at };
72
- await mkdir(dir, { recursive: true, mode: 0o700 });
73
- await appendFile(fileFor(new Date(at)), `${JSON.stringify(record)}\n`, { mode: 0o600 });
74
- return record;
75
- },
76
- flush,
77
- dispose() {
78
- if (timer) { clearTimeout(timer); timer = undefined; }
79
- return flush();
80
- },
81
- };
82
- }