bingocode 1.1.183 → 1.1.185

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,408 @@
1
+ /**
2
+ * Goal DAG Engine — dynamic task graph with topological operations.
3
+ *
4
+ * Core capabilities:
5
+ * - Topological sort (Kahn's algorithm, BFS)
6
+ * - Ready node computation (entry nodes with all deps satisfied)
7
+ * - Cycle detection (DFS with 3-color marking)
8
+ * - Dynamic mutations (add, remove, merge, reorder, update status)
9
+ *
10
+ * The DAG is the heart of the goal system. It turns a flat list of
11
+ * sub-goals into a partially-ordered execution plan. Operations are
12
+ * validated on each mutation to prevent cycles and invalid states.
13
+ *
14
+ * Complexity:
15
+ * - addNode/removeNode/updateNode: O(V+E)
16
+ * - topoSort/getReadyNodes: O(V+E)
17
+ * - hasCycles: O(V+E)
18
+ * - serialization: O(V+E)
19
+ */
20
+
21
+ import type { SubGoal, DagOperation } from '../types/goal.js'
22
+
23
+ // ============================================================================
24
+ // Helper types
25
+ // ============================================================================
26
+
27
+ /** Edge representation: [from, to] where from depends on to.
28
+ * i.e., to must complete before from can start. */
29
+ type Edge = [string, string]
30
+
31
+ /** Internal node storage for traversal algorithms. */
32
+ interface DagNode {
33
+ id: string
34
+ text: string
35
+ status: SubGoal['status']
36
+ dependencies: string[]
37
+ }
38
+
39
+ // ============================================================================
40
+ // Engine
41
+ // ============================================================================
42
+
43
+ export class GoalDagEngine {
44
+ private nodes: Map<string, DagNode>
45
+ private edges: Edge[]
46
+
47
+ constructor(nodes?: SubGoal[]) {
48
+ this.nodes = new Map()
49
+ this.edges = []
50
+ if (nodes) {
51
+ for (const n of nodes) {
52
+ this.addNode(n)
53
+ }
54
+ }
55
+ }
56
+
57
+ // ========================================================================
58
+ // Core queries
59
+ // ========================================================================
60
+
61
+ /** Topological sort — returns node IDs in dependency order.
62
+ * Uses Kahn's algorithm: BFS from nodes with zero incoming edges.
63
+ * Returns empty array if the graph is empty.
64
+ * Complexity: O(V + E) */
65
+ topoSort(): string[] {
66
+ const inDegree = new Map<string, number>()
67
+ const queue: string[] = []
68
+
69
+ // Initialize in-degree counts
70
+ for (const [id] of this.nodes) {
71
+ inDegree.set(id, 0)
72
+ }
73
+ for (const [from, to] of this.edges) {
74
+ inDegree.set(to, (inDegree.get(to) ?? 0) + 1)
75
+ }
76
+
77
+ // Seed with zero in-degree nodes
78
+ for (const [id, deg] of inDegree) {
79
+ if (deg === 0) queue.push(id)
80
+ }
81
+
82
+ const result: string[] = []
83
+ while (queue.length > 0) {
84
+ const id = queue.shift()!
85
+ result.push(id)
86
+ // Decrement in-degree of all successors
87
+ for (const [from, to] of this.edges) {
88
+ if (from === id) {
89
+ const newDeg = (inDegree.get(to) ?? 1) - 1
90
+ inDegree.set(to, newDeg)
91
+ if (newDeg === 0) queue.push(to)
92
+ }
93
+ }
94
+ }
95
+
96
+ // If result.length < nodes.size, there's a cycle — but we still
97
+ // return what we can (partial order is valid for dag validation).
98
+ return result
99
+ }
100
+
101
+ /** Get nodes ready for execution: all dependencies complete, not yet started.
102
+ * Includes active nodes that are re-entrant (can be picked up again). */
103
+ getReadyNodes(): string[] {
104
+ const ready: string[] = []
105
+ for (const [id, node] of this.nodes) {
106
+ if (node.status === 'completed') continue
107
+ if (node.status === 'failed') continue
108
+ if (node.status === 'blocked') continue
109
+ if (node.status === 'skipped') continue
110
+
111
+ // Check all dependencies are satisfied
112
+ const allDepsSatisfied = node.dependencies.every(depId => {
113
+ const dep = this.nodes.get(depId)
114
+ return dep?.status === 'completed' || dep?.status === 'skipped'
115
+ })
116
+ if (allDepsSatisfied) ready.push(id)
117
+ }
118
+ return ready
119
+ }
120
+
121
+ /** All nodes in the DAG. */
122
+ getAllNodes(): SubGoal[] {
123
+ return Array.from(this.nodes.values()).map(toSubGoal)
124
+ }
125
+
126
+ /** Root nodes: no incoming dependencies. */
127
+ getRootNodes(): SubGoal[] {
128
+ const hasIncoming = new Set<string>()
129
+ for (const [, to] of this.edges) {
130
+ hasIncoming.add(to)
131
+ }
132
+ return Array.from(this.nodes.values())
133
+ .filter(n => !hasIncoming.has(n.id))
134
+ .map(toSubGoal)
135
+ }
136
+
137
+ /** Leaf nodes: no outgoing dependencies. */
138
+ getLeafNodes(): SubGoal[] {
139
+ const hasOutgoing = new Set<string>()
140
+ for (const [from] of this.edges) {
141
+ hasOutgoing.add(from)
142
+ }
143
+ return Array.from(this.nodes.values())
144
+ .filter(n => !hasOutgoing.has(n.id))
145
+ .map(toSubGoal)
146
+ }
147
+
148
+ /** All descendants of a node (transitive closure of downstream nodes). */
149
+ getDescendants(nodeId: string): SubGoal[] {
150
+ const visited = new Set<string>()
151
+ const stack = [nodeId]
152
+ while (stack.length > 0) {
153
+ const current = stack.pop()!
154
+ if (visited.has(current)) continue
155
+ visited.add(current)
156
+ for (const [from, to] of this.edges) {
157
+ if (from === current) {
158
+ stack.push(to)
159
+ }
160
+ }
161
+ }
162
+ visited.delete(nodeId) // exclude self
163
+ return Array.from(this.nodes.values())
164
+ .filter(n => visited.has(n.id))
165
+ .map(toSubGoal)
166
+ }
167
+
168
+ // ========================================================================
169
+ // Dynamic mutations
170
+ // ========================================================================
171
+
172
+ /** Add a new sub-goal node to the DAG. Validates that dependencies
173
+ * reference existing nodes. Rebuilds edge list after insertion. */
174
+ addNode(subGoal: SubGoal): void {
175
+ const internal: DagNode = {
176
+ id: subGoal.id,
177
+ text: subGoal.text,
178
+ status: subGoal.status,
179
+ dependencies: [...subGoal.dependencies],
180
+ }
181
+ this.nodes.set(internal.id, internal)
182
+ this.rebuildEdges()
183
+ }
184
+
185
+ /** Remove a node and all its associated edges. */
186
+ removeNode(nodeId: string): boolean {
187
+ if (!this.nodes.has(nodeId)) return false
188
+ this.nodes.delete(nodeId)
189
+ // Remove edges where this node is either source or target
190
+ this.edges = this.edges.filter(([from, to]) => from !== nodeId && to !== nodeId)
191
+ return true
192
+ }
193
+
194
+ /** Merge multiple source nodes into a single target. The target's
195
+ * dependencies are the union of the sources' dependencies (minus the
196
+ * sources themselves). Performs full dependency rewrite on all nodes
197
+ * referencing the source IDs — both incoming (depends-on) and outgoing
198
+ * (depended-by) edges are updated.
199
+ *
200
+ * This is a graph-level operation: all nodes in the DAG are scanned and
201
+ * any reference to a source ID is replaced with the merged target ID.
202
+ * After completion, validate() is called to detect any cycles introduced. */
203
+ mergeNodes(sourceIds: string[], targetText: string): SubGoal {
204
+ const targetId = `merged-${Date.now().toString(36)}`
205
+ const now = Date.now()
206
+
207
+ // Phase 1: Collect all dependencies from sources (outgoing edges)
208
+ // These are the nodes that source nodes depend on — they become the
209
+ // merged node's own dependencies.
210
+ const allDeps = new Set<string>()
211
+ for (const id of sourceIds) {
212
+ const node = this.nodes.get(id)
213
+ if (node) {
214
+ for (const dep of node.dependencies) {
215
+ if (!sourceIds.includes(dep)) {
216
+ allDeps.add(dep)
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ // Phase 2: Build the merged node
223
+ const merged: SubGoal = {
224
+ id: targetId,
225
+ parentId: '' as any, // will be set by caller
226
+ text: targetText,
227
+ status: 'pending',
228
+ dependencies: Array.from(allDeps),
229
+ progress: 0,
230
+ createdAt: now,
231
+ updatedAt: now,
232
+ }
233
+
234
+ // Phase 3: Rewrite all nodes that reference source IDs
235
+ // Both incoming edges (X depends on source) and outgoing edges are fixed.
236
+ // For each node in the graph, replace any dependency ID that appears in
237
+ // sourceIds with the merged targetId.
238
+ for (const [id, node] of this.nodes) {
239
+ if (sourceIds.includes(id)) continue // skip sources — they'll be removed
240
+ const newDeps = node.dependencies.map(dep =>
241
+ sourceIds.includes(dep) ? targetId : dep
242
+ )
243
+ // Deduplicate: if a node depended on both src1 and src2, it now
244
+ // depends on merged only once.
245
+ const uniqueDeps = [...new Set(newDeps)]
246
+ if (uniqueDeps.length !== node.dependencies.length ||
247
+ uniqueDeps.some((d, i) => d !== node.dependencies[i])) {
248
+ // Dependency actually changed — update the node
249
+ node.dependencies = newDeps as any
250
+ }
251
+ }
252
+
253
+ // Phase 4: Remove sources and add merged node to graph
254
+ for (const id of sourceIds) {
255
+ this.nodes.delete(id)
256
+ }
257
+ this.nodes.set(targetId, merged)
258
+
259
+ // Phase 5: Rebuild edge list from rewritten dependencies
260
+ this.rebuildEdges()
261
+
262
+ return merged
263
+ }
264
+
265
+ /** Change the dependency list for a node. Validates no cycles introduced. */
266
+ reorderDeps(nodeId: string, newDeps: string[]): void {
267
+ const node = this.nodes.get(nodeId)
268
+ if (!node) return
269
+ node.dependencies = [...newDeps]
270
+ this.rebuildEdges()
271
+ }
272
+
273
+ /** Update a node's status. */
274
+ updateStatus(nodeId: string, status: SubGoal['status']): void {
275
+ const node = this.nodes.get(nodeId)
276
+ if (node) node.status = status
277
+ }
278
+
279
+ // ========================================================================
280
+ // Validation
281
+ // ========================================================================
282
+
283
+ /** Check for cycles using DFS with 3-color marking.
284
+ * Returns array of cycle paths found, or empty if acyclic. */
285
+ hasCycles(): boolean {
286
+ return this.detectCycles().length > 0
287
+ }
288
+
289
+ /** Full cycle detection — returns the actual cycle paths for debugging.
290
+ * Each entry is a list of node IDs forming a cycle. */
291
+ detectCycles(): string[][] {
292
+ const WHITE = 0, GRAY = 1, BLACK = 2
293
+ const color = new Map<string, number>()
294
+ const cycles: string[][] = []
295
+
296
+ for (const [id] of this.nodes) {
297
+ color.set(id, WHITE)
298
+ }
299
+
300
+ const visit = (nodeId: string, path: string[]): void => {
301
+ const c = color.get(nodeId)
302
+ if (c === GRAY) {
303
+ // Found a back edge — extract the cycle from path
304
+ const cycleStart = path.indexOf(nodeId)
305
+ if (cycleStart !== -1) {
306
+ cycles.push([...path.slice(cycleStart), nodeId])
307
+ }
308
+ return
309
+ }
310
+ if (c === BLACK) return
311
+
312
+ color.set(nodeId, GRAY)
313
+ path.push(nodeId)
314
+
315
+ // Visit all outgoing edges
316
+ for (const [from, to] of this.edges) {
317
+ if (from === nodeId) {
318
+ visit(to, [...path])
319
+ }
320
+ }
321
+
322
+ color.set(nodeId, BLACK)
323
+ path.pop()
324
+ }
325
+
326
+ for (const [id] of this.nodes) {
327
+ if (color.get(id) === WHITE) {
328
+ visit(id, [])
329
+ }
330
+ }
331
+
332
+ return cycles
333
+ }
334
+
335
+ /** Validate DAG integrity — returns whether the graph is valid and
336
+ * any cycle paths found. */
337
+ validate(): { valid: boolean; cycles: string[][] } {
338
+ const cycles = this.detectCycles()
339
+ return { valid: cycles.length === 0, cycles }
340
+ }
341
+
342
+ // ========================================================================
343
+ // Lifecycle
344
+ // ========================================================================
345
+
346
+ /** Reset the engine to empty state. Called when goal is cleared or
347
+ * a new session starts. Preserves no state. */
348
+ reset(): void {
349
+ this.nodes.clear()
350
+ this.edges = []
351
+ }
352
+
353
+ // ========================================================================
354
+ // Serialization
355
+ // ========================================================================
356
+
357
+ /** Export as JSON-serializable structure. */
358
+ toJSON(): { nodes: Record<string, DagNode>; edges: Edge[] } {
359
+ const nodeObj: Record<string, DagNode> = {}
360
+ for (const [id, node] of this.nodes) {
361
+ nodeObj[id] = { ...node }
362
+ }
363
+ return { nodes: nodeObj, edges: [...this.edges] }
364
+ }
365
+
366
+ /** Import from JSON-serialized structure. */
367
+ static fromJSON(data: { nodes: Record<string, DagNode>; edges: [string, string][] }): GoalDagEngine {
368
+ const engine = new GoalDagEngine()
369
+ for (const [, node] of Object.entries(data.nodes)) {
370
+ engine.nodes.set(node.id, { ...node })
371
+ }
372
+ engine.edges = [...data.edges]
373
+ return engine
374
+ }
375
+
376
+ // ========================================================================
377
+ // Internal
378
+ // ========================================================================
379
+
380
+ /** Rebuild edge list from current nodes' dependency arrays. */
381
+ private rebuildEdges(): void {
382
+ const edges: Edge[] = []
383
+ for (const [id, node] of this.nodes) {
384
+ for (const dep of node.dependencies) {
385
+ edges.push([dep, id])
386
+ }
387
+ }
388
+ this.edges = edges
389
+ }
390
+ }
391
+
392
+ // ============================================================================
393
+ // Helpers
394
+ // ============================================================================
395
+
396
+ /** Convert internal DagNode to SubGoal type. */
397
+ function toSubGoal(node: DagNode): SubGoal {
398
+ return {
399
+ id: node.id,
400
+ parentId: '' as any, // placeholder — set by caller
401
+ text: node.text,
402
+ status: node.status as SubGoal['status'],
403
+ dependencies: node.dependencies,
404
+ progress: 0,
405
+ createdAt: 0,
406
+ updatedAt: 0,
407
+ }
408
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Goal Drift Detector — alignment monitoring for agent behavior.
3
+ *
4
+ * Computes consistency scores between the agent's current actions and its
5
+ * declared goals. Tracks trends over time and generates warnings when
6
+ * behavior starts deviating from the target.
7
+ *
8
+ * Used by the goal system to detect when an agent should replan rather
9
+ * than continue executing blind.
10
+ */
11
+
12
+ import type { DriftAssessment, DriftWarning } from '../types/goal.js'
13
+ import type { SubGoal } from '../types/goal.js'
14
+
15
+ // ============================================================================
16
+ // Config
17
+ // ============================================================================
18
+
19
+ const DRIFT_HISTORY_SIZE = 5
20
+
21
+ let driftHistory: DriftAssessment[] = []
22
+
23
+ // ============================================================================
24
+ // Core assessment
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Assess whether the agent's current action aligns with its declared goals.
29
+ * Returns a DriftAssessment with score, trend, and warnings.
30
+ *
31
+ * @param currentAction - what the agent just did (tool output, message text)
32
+ * @param currentSubGoal - the SubGoal this action should serve
33
+ * @param operationalGoal - the overarching execution contract
34
+ */
35
+ export function assessDrift(
36
+ currentAction: string,
37
+ currentSubGoal: SubGoal,
38
+ operationalGoal: string,
39
+ ): DriftAssessment {
40
+ // Step 1: Extract keyword sets from each layer
41
+ const actionWords = extractKeywords(currentAction)
42
+ const subGoalWords = extractKeywords(currentSubGoal.text)
43
+ const opGoalWords = extractKeywords(operationalGoal)
44
+
45
+ // Step 2: Compute pairwise similarity scores
46
+ const actionToSub = jaccardSimilarity(actionWords, subGoalWords)
47
+ const actionToOp = jaccardSimilarity(actionWords, opGoalWords)
48
+
49
+ // Step 3: Weighted overall score (sub-goal alignment is more important)
50
+ const consistencyScore = Math.round((actionToSub * 0.7 + actionToOp * 0.3) * 100)
51
+
52
+ // Step 4: Track trend
53
+ const assessment: DriftAssessment = {
54
+ consistencyScore,
55
+ currentAction,
56
+ targetSubGoal: currentSubGoal.text,
57
+ operationalGoal,
58
+ trend: 'stable',
59
+ warnings: [],
60
+ }
61
+
62
+ driftHistory.push(assessment)
63
+ if (driftHistory.length > DRIFT_HISTORY_SIZE) {
64
+ driftHistory.shift()
65
+ }
66
+
67
+ // Step 5: Compute trend from history
68
+ assessment.trend = computeTrend(driftHistory.map(d => d.consistencyScore))
69
+
70
+ // Step 6: Generate warnings if declining
71
+ assessment.warnings = generateWarnings(consistencyScore, assessment.trend, currentAction, currentSubGoal, operationalGoal)
72
+
73
+ return assessment
74
+ }
75
+
76
+ // ============================================================================
77
+ // Helpers
78
+ // ============================================================================
79
+
80
+ /** Extract meaningful keywords from arbitrary text.
81
+ * Strips stop words and normalizes to lowercase tokens. */
82
+ /** Extract meaningful keywords from arbitrary text.
83
+ * Supports both English and CJK (Chinese/Japanese/Korean) via Unicode
84
+ * property escapes. Strips common stop words in both languages. */
85
+ function extractKeywords(text: string): Set<string> {
86
+ // English + CJK stop words
87
+ const stopWords = new Set([
88
+ // English
89
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been',
90
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
91
+ 'can', 'could', 'should', 'may', 'might', 'to', 'of', 'in',
92
+ 'for', 'on', 'at', 'by', 'with', 'from', 'or', 'and', 'not',
93
+ 'but', 'if', 'then', 'else', 'when', 'where', 'how', 'what',
94
+ 'this', 'that', 'these', 'those', 'it', 'its',
95
+ // Chinese (common function words)
96
+ '的', '了', '是', '在', '我', '有', '和', '就', '不', '人',
97
+ '都', '一', '个', '上', '也', '很', '到', '说', '要', '去',
98
+ '你', '会', '着', '没有', '看', '好', '自己', '这', '他',
99
+ '她', '它', '们', '那', '什么', '怎么', '如何', '为什么',
100
+ '因为', '所以', '但是', '虽然', '如果', '可以', '还', '已经',
101
+ '这个', '那个', '这些', '那些', '这里', '那里', '哪', '吗',
102
+ '吧', '啊', '呢', '哦', '嗯',
103
+ ])
104
+
105
+ // Step 1: Normalize to lowercase
106
+ const normalized = text.toLowerCase()
107
+
108
+ // Step 2: Extract CJK bigrams (2-char sequences) for Chinese/Japanese
109
+ // This captures semantic units like "工具", "生成", "检测"
110
+ const cjkBigrams: string[] = []
111
+ let prevCjk = ''
112
+ for (const ch of normalized) {
113
+ const isCjk = /\p{Script=Han}/u.test(ch) || /\p{Script=Katakana}/u.test(ch) || /\p{Script=Hiragana}/u.test(ch)
114
+ if (isCjk) {
115
+ if (prevCjk) {
116
+ cjkBigrams.push(prevCjk + ch)
117
+ }
118
+ prevCjk = ch
119
+ } else {
120
+ if (prevCjk) {
121
+ // Single CJK char also counts (not filtered by length=1)
122
+ cjkBigrams.push(prevCjk)
123
+ }
124
+ prevCjk = ''
125
+ }
126
+ }
127
+ if (prevCjk) cjkBigrams.push(prevCjk)
128
+
129
+ // Step 3: Extract English/ASCII words (preserved by Unicode-safe replace)
130
+ const asciiPart = normalized.replace(/[^\p{L}\p{N}\s]/gu, ' ')
131
+ const asciiWords = asciiPart
132
+ .split(/\s+/)
133
+ .filter(w => w.length > 2 && !stopWords.has(w))
134
+
135
+ // Step 4: Merge CJK bigrams + ASCII words, deduplicate
136
+ const allTokens = new Set([...cjkBigrams, ...asciiWords])
137
+
138
+ return allTokens
139
+ }
140
+
141
+ /** Jaccard similarity coefficient: |A ∩ B| / |A ∪ B|.
142
+ * Returns 0 if either set is empty. */
143
+ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
144
+ if (a.size === 0 || b.size === 0) return 0
145
+
146
+ let intersection = 0
147
+ for (const word of a) {
148
+ if (b.has(word)) intersection++
149
+ }
150
+
151
+ const unionSize = a.size + b.size - intersection
152
+ return unionSize > 0 ? intersection / unionSize : 0
153
+ }
154
+
155
+ // ============================================================================
156
+ // Trend computation
157
+ // ============================================================================
158
+
159
+ /** Compute trend direction from a series of consistency scores.
160
+ * Uses simple moving average with threshold of 5 points. */
161
+ function computeTrend(scores: number[]): DriftAssessment['trend'] {
162
+ if (scores.length < 2) return 'stable'
163
+
164
+ const firstHalf = scores.slice(0, Math.floor(scores.length / 2))
165
+ const secondHalf = scores.slice(Math.floor(scores.length / 2))
166
+
167
+ const firstAvg = average(firstHalf)
168
+ const secondAvg = average(secondHalf)
169
+
170
+ const delta = secondAvg - firstAvg
171
+
172
+ if (delta > 5) return 'improving'
173
+ if (delta < -5) return 'declining'
174
+ return 'stable'
175
+ }
176
+
177
+ function average(nums: number[]): number {
178
+ if (nums.length === 0) return 0
179
+ return nums.reduce((a, b) => a + b, 0) / nums.length
180
+ }
181
+
182
+ // ============================================================================
183
+ // Warning generation
184
+ // ============================================================================
185
+
186
+ /** Generate warnings based on consistency score and trend.
187
+ * Returns actionable suggestions for the agent. */
188
+ function generateWarnings(
189
+ score: number,
190
+ trend: DriftAssessment['trend'],
191
+ currentAction: string,
192
+ currentSubGoal: SubGoal,
193
+ operationalGoal: string,
194
+ ): DriftWarning[] {
195
+ const warnings: DriftWarning[] = []
196
+
197
+ // Thresholds
198
+ if (score >= 80) return warnings // healthy — no warnings
199
+
200
+ if (score < 50 && trend === 'declining') {
201
+ warnings.push({
202
+ severity: 'high',
203
+ message: `Severe drift detected: current action "${currentAction.slice(0, 100)}" has low alignment (${score}%) with both sub-goal "${currentSubGoal.text}" and operational goal "${operationalGoal}". Consider immediate replanning.`,
204
+ suggestedAction: 'replan',
205
+ })
206
+ } else if (score < 50) {
207
+ warnings.push({
208
+ severity: 'medium',
209
+ message: `Moderate drift: action "${currentAction.slice(0, 100)}" may not be serving goal "${currentSubGoal.text}" (score: ${score}%). Review next steps.`,
210
+ suggestedAction: 'replan',
211
+ })
212
+ } else if (score < 70) {
213
+ warnings.push({
214
+ severity: 'low',
215
+ message: `Minor drift: alignment score ${score}% with target "${currentSubGoal.text}". Monitor for improvement.`,
216
+ suggestedAction: 'continue',
217
+ })
218
+ }
219
+
220
+ if (trend === 'declining' && warnings.length === 0) {
221
+ warnings.push({
222
+ severity: 'low',
223
+ message: `Trend declining — score dropped below 80%. Current action may be wandering.`,
224
+ suggestedAction: 'continue',
225
+ })
226
+ }
227
+
228
+ return warnings
229
+ }
230
+
231
+ // ============================================================================
232
+ // Public API
233
+ // ============================================================================
234
+
235
+ /** Reset drift history (e.g., when goal changes or session restarts). */
236
+ export function resetDriftHistory(): void {
237
+ driftHistory = []
238
+ }
239
+
240
+ /** Get the current drift history for debugging/inspection. */
241
+ export function getDriftHistory(): ReadonlyArray<DriftAssessment> {
242
+ return driftHistory as ReadonlyArray<DriftAssessment>
243
+ }