bingocode 1.1.183 → 1.1.184
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/.claude/scheduled_tasks.json +34 -0
- package/package.json +1 -1
- package/src/bootstrap/state.ts +61 -13
- package/src/hooks/useGoalAutoUpdater.ts +187 -0
- package/src/hooks/useGoalEvaluator.ts +29 -16
- package/src/query.ts +21 -0
- package/src/skills/bundled/goal.ts +41 -32
- package/src/types/goal.ts +322 -0
- package/src/utils/__tests__/goalDag.test.ts +177 -0
- package/src/utils/__tests__/goalDrift.test.ts +132 -0
- package/src/utils/__tests__/goalIntegration.test.ts +217 -0
- package/src/utils/__tests__/goalStore.test.ts +592 -0
- package/src/utils/goalDag.ts +408 -0
- package/src/utils/goalDrift.ts +243 -0
- package/src/utils/goalEvaluator.ts +708 -122
- package/src/utils/goalHelpers.ts +155 -0
- package/src/utils/goalPersistence.ts +206 -0
- package/src/utils/goalStore.ts +463 -0
|
@@ -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
|
+
}
|