@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/LICENSE +21 -0
- package/README.md +179 -0
- package/index.mjs +674 -0
- package/package.json +82 -0
- package/packaging/README.md +78 -0
- package/packaging/nixos/agents.nix.snippet +46 -0
- package/packaging/nixos/default.nix.snippet +6 -0
- package/packaging/nixos/dsh-thinking-auditor.nix +54 -0
- package/src/async.mjs +90 -0
- package/src/auditor.mjs +252 -0
- package/src/claims.mjs +520 -0
- package/src/config.mjs +243 -0
- package/src/file-sink.mjs +76 -0
- package/src/ledger.mjs +398 -0
- package/src/policy.mjs +189 -0
- package/src/stakes.mjs +104 -0
- package/src/state.mjs +131 -0
- package/src/store.mjs +185 -0
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
|
+
}
|