@codebam/dsh-thinking-auditor 0.1.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/src/state.mjs ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Per-session audit state: the bounded reasoning window, trigger bookkeeping,
3
+ * trailing verdict history, and once-per-turn actuator latches.
4
+ *
5
+ * Pure data structure, so trigger/dedupe behavior is testable without a model.
6
+ *
7
+ * @module @codebam/dsh-thinking-auditor/state
8
+ */
9
+
10
+ /**
11
+ * One session's live audit state.
12
+ *
13
+ * The reasoning window is a tail-bounded string: auditing takes the most recent
14
+ * `maxTraceChars` characters and clears the window, so memory cannot grow with a
15
+ * long autonomous turn and each window is audited exactly once.
16
+ */
17
+ export class SessionAuditState {
18
+ constructor(options = {}) {
19
+ this.sessionId = String(options.sessionId ?? '')
20
+ this.maxTraceChars = options.maxTraceChars ?? 12000
21
+ /** Unaudited reasoning text. */
22
+ this.pending = ''
23
+ /** Total reasoning characters observed this session. */
24
+ this.reasoningChars = 0
25
+ /** Current turn/step context. */
26
+ this.turn = options.turn ?? 0
27
+ this.step = options.step ?? 0
28
+ /** Background audits started this turn. */
29
+ this.auditsThisTurn = 0
30
+ /** Session-time of the last accepted audit start. */
31
+ this.lastAuditAt = 0
32
+ /** In-flight audit promise, or undefined. */
33
+ this.inFlight = undefined
34
+ /** Set when an audit was requested while one was in flight. */
35
+ this.rerunRequested = false
36
+ /** Consecutive verdict history for hysteresis. */
37
+ this.history = []
38
+ /** Turns already given a T1 verification steer. */
39
+ this.verifiedTurns = new Set()
40
+ /** Turns already cancelled by T3. */
41
+ this.cancelledTurns = new Set()
42
+ /** Latest committed verdict record. */
43
+ this.lastVerdict = undefined
44
+ /** True once the session was disposed. */
45
+ this.disposed = false
46
+ }
47
+
48
+ /** Append reasoning text and return the new pending length. */
49
+ appendReasoning(text, { turn, step } = {}) {
50
+ if (typeof text !== 'string' || text.length === 0) return this.pending.length
51
+ if (turn !== undefined) this.turn = turn
52
+ if (step !== undefined) this.step = step
53
+ this.pending += text
54
+ this.reasoningChars += text.length
55
+ const bound = this.maxTraceChars * 2
56
+ if (this.pending.length > bound) this.pending = this.pending.slice(-bound)
57
+ return this.pending.length
58
+ }
59
+
60
+ /** Reset per-turn triggers and hysteresis on a fresh user turn. */
61
+ startTurn(turn) {
62
+ this.turn = turn ?? this.turn + 1
63
+ this.auditsThisTurn = 0
64
+ this.history = []
65
+ // Keep latches bounded across very long sessions.
66
+ for (const recorded of [...this.verifiedTurns]) if (recorded < this.turn - 50) this.verifiedTurns.delete(recorded)
67
+ for (const recorded of [...this.cancelledTurns]) if (recorded < this.turn - 50) this.cancelledTurns.delete(recorded)
68
+ }
69
+
70
+ /** True when enough unaudited reasoning is pending for an opportunistic trigger. */
71
+ hasPending(minChars) {
72
+ return this.pending.length >= minChars
73
+ }
74
+
75
+ /** True when an audit window should start at this moment. */
76
+ shouldSchedule({ windowChars, cooldownMs, now = Date.now() }) {
77
+ if (this.inFlight !== undefined) return false
78
+ if (this.pending.length >= windowChars) return true
79
+ return this.pending.length > 0 && now - this.lastAuditAt >= cooldownMs
80
+ }
81
+
82
+ /** Take the audit window, clearing it. Keeps only the most recent cap. */
83
+ takeWindow(maxChars = this.maxTraceChars) {
84
+ const text = this.pending.length <= maxChars ? this.pending : this.pending.slice(-maxChars)
85
+ this.pending = ''
86
+ return { text, turn: this.turn, step: this.step, chars: text.length }
87
+ }
88
+
89
+ /** Record an accepted audit start. */
90
+ markAuditStarted(now = Date.now()) {
91
+ this.lastAuditAt = now
92
+ this.auditsThisTurn += 1
93
+ }
94
+
95
+ /** True when another audit may start this turn. */
96
+ canAuditThisTurn(max) {
97
+ return this.auditsThisTurn < max
98
+ }
99
+
100
+ /** True when this turn already received its T1 steer. */
101
+ wasVerified(turn = this.turn) {
102
+ return this.verifiedTurns.has(turn)
103
+ }
104
+
105
+ /** Latch T1 for a turn. */
106
+ latchVerified(turn = this.turn) {
107
+ this.verifiedTurns.add(turn)
108
+ }
109
+
110
+ /** True when this turn already received its T3 cancel. */
111
+ wasCancelled(turn = this.turn) {
112
+ return this.cancelledTurns.has(turn)
113
+ }
114
+
115
+ /** Latch T3 for a turn. */
116
+ latchCancelled(turn = this.turn) {
117
+ this.cancelledTurns.add(turn)
118
+ }
119
+
120
+ /** Append a verdict to bounded hysteresis history. */
121
+ pushHistory(record, cap = 12) {
122
+ this.history.push({
123
+ level: record.level,
124
+ at: record.at,
125
+ confirmedFabrication: record.confirmedFabrication,
126
+ id: record.id,
127
+ })
128
+ while (this.history.length > cap) this.history.shift()
129
+ return this.history
130
+ }
131
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Audit record store and verdict/action notification hub.
3
+ *
4
+ * Records live in their own bounded in-memory store, separate from the session
5
+ * log: the session format deliberately treats unknown event types as required
6
+ * unless persisted with an `ignorable` envelope marker, and this plugin never
7
+ * writes to the session log. An optional JSONL sink makes verdicts durable
8
+ * without touching session compatibility.
9
+ *
10
+ * Pure module except for the sink callback the caller supplies.
11
+ *
12
+ * @module @codebam/dsh-thinking-auditor/store
13
+ */
14
+
15
+ let sequence = 0
16
+
17
+ /** Deep-freeze a plain record. */
18
+ function freeze(value) {
19
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value
20
+ for (const nested of Object.values(value)) freeze(nested)
21
+ return Object.freeze(value)
22
+ }
23
+
24
+ /** Detach one record from the store's frozen value. */
25
+ function detach(record) {
26
+ return record === undefined ? undefined : structuredClone(record)
27
+ }
28
+
29
+ /** One session's bounded record window. */
30
+ class SessionRecords {
31
+ constructor(maxRecords) {
32
+ this.maxRecords = maxRecords
33
+ /** @type {object[]} */
34
+ this.verdicts = []
35
+ /** @type {object[]} */
36
+ this.actions = []
37
+ }
38
+
39
+ push(list, record) {
40
+ list.push(record)
41
+ while (list.length > this.maxRecords) list.shift()
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Bounded audit store.
47
+ *
48
+ * `record(verdict)` and `recordAction(action)` return frozen records and fan
49
+ * them out to subscribers. Sink failures are contained; they never affect the
50
+ * audit path or the monitored model.
51
+ */
52
+ export class AuditStore {
53
+ constructor(options = {}) {
54
+ this.maxSessions = options.maxSessions ?? 200
55
+ this.maxRecordsPerSession = options.maxRecordsPerSession ?? 200
56
+ /** @type {Map<string, SessionRecords>} */
57
+ this.sessions = new Map()
58
+ /** @type {Set<Function>} */
59
+ this.subscribers = new Set()
60
+ /** @type {Function[]} */
61
+ this.sinks = []
62
+ /** @type {string|undefined} */
63
+ this.lastSinkError = undefined
64
+ }
65
+
66
+ /** True when no audit has been recorded. */
67
+ get isEmpty() {
68
+ return this.sessions.size === 0
69
+ }
70
+
71
+ /** Add a durable sink invoked after every record. */
72
+ addSink(sink) {
73
+ if (typeof sink !== 'function') throw new TypeError('thinking-auditor: store sink must be a function')
74
+ this.sinks.push(sink)
75
+ return () => {
76
+ const index = this.sinks.indexOf(sink)
77
+ if (index !== -1) this.sinks.splice(index, 1)
78
+ }
79
+ }
80
+
81
+ /** Subscribe to committed records. */
82
+ subscribe(callback) {
83
+ if (typeof callback !== 'function') throw new TypeError('thinking-auditor: store subscriber must be a function')
84
+ this.subscribers.add(callback)
85
+ return () => this.subscribers.delete(callback)
86
+ }
87
+
88
+ /** Get or create one session's record window with bounded session count. */
89
+ #session(sessionId) {
90
+ const key = String(sessionId)
91
+ let records = this.sessions.get(key)
92
+ if (records === undefined) {
93
+ while (this.sessions.size >= this.maxSessions) {
94
+ const oldest = this.sessions.keys().next().value
95
+ this.sessions.delete(oldest)
96
+ }
97
+ records = new SessionRecords(this.maxRecordsPerSession)
98
+ this.sessions.set(key, records)
99
+ }
100
+ return records
101
+ }
102
+
103
+ /** Commit one verdict record. */
104
+ record(verdict) {
105
+ const stored = freeze({
106
+ ...verdict,
107
+ id: verdict.id ?? `verdict-${++sequence}`,
108
+ at: verdict.at ?? Date.now(),
109
+ })
110
+ const records = this.#session(stored.sessionId)
111
+ records.push(records.verdicts, stored)
112
+ this.#publish({ kind: 'verdict', record: stored })
113
+ return stored
114
+ }
115
+
116
+ /** Commit one actuator action for a verdict. */
117
+ recordAction(action) {
118
+ const stored = freeze({
119
+ ...action,
120
+ id: action.id ?? `action-${++sequence}`,
121
+ at: action.at ?? Date.now(),
122
+ })
123
+ const records = this.#session(stored.sessionId)
124
+ records.push(records.actions, stored)
125
+ this.#publish({ kind: 'action', record: stored })
126
+ return stored
127
+ }
128
+
129
+ /** Fan a record out to subscribers and sinks, containing every failure. */
130
+ #publish(event) {
131
+ for (const subscriber of this.subscribers) {
132
+ try {
133
+ subscriber(event)
134
+ } catch {
135
+ // Observer failures are contained: auditing must never break a session.
136
+ }
137
+ }
138
+ for (const sink of this.sinks) {
139
+ try {
140
+ const result = sink(event.record)
141
+ if (result !== undefined && typeof result.then === 'function') {
142
+ result.catch((error) => {
143
+ this.lastSinkError = error?.message ?? String(error)
144
+ })
145
+ }
146
+ } catch (error) {
147
+ this.lastSinkError = error?.message ?? String(error)
148
+ }
149
+ }
150
+ }
151
+
152
+ /** Detached verdicts for one session, oldest first. */
153
+ verdicts(sessionId, limit) {
154
+ const records = this.sessions.get(String(sessionId))
155
+ const list = records?.verdicts ?? []
156
+ const selected = limit === undefined ? list : list.slice(-limit)
157
+ return selected.map(detach)
158
+ }
159
+
160
+ /** Detached actions for one session, oldest first. */
161
+ actions(sessionId, limit) {
162
+ const records = this.sessions.get(String(sessionId))
163
+ const list = records?.actions ?? []
164
+ const selected = limit === undefined ? list : list.slice(-limit)
165
+ return selected.map(detach)
166
+ }
167
+
168
+ /** Latest verdict for one session, or undefined. */
169
+ latest(sessionId) {
170
+ const records = this.sessions.get(String(sessionId))
171
+ return detach(records?.verdicts[records.verdicts.length - 1])
172
+ }
173
+
174
+ /** Number of sessions currently retained. */
175
+ get size() {
176
+ return this.sessions.size
177
+ }
178
+
179
+ /** Total verdict records currently retained. */
180
+ get verdictCount() {
181
+ let total = 0
182
+ for (const records of this.sessions.values()) total += records.verdicts.length
183
+ return total
184
+ }
185
+ }