@mindot/will 0.8.0 → 0.9.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/dist/channels/discord.d.ts +67 -6
- package/dist/channels/discord.js +112 -6
- package/dist/channels/discord.js.map +1 -1
- package/dist/channels/whatsapp.d.ts +1 -1
- package/dist/channels/whatsapp.js +4 -1
- package/dist/channels/whatsapp.js.map +1 -1
- package/dist/cli.js +3174 -867
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3236 -1042
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-cS6k4uiJ.d.ts → will-DbDj_TEH.d.ts} +752 -17
- package/package.json +1 -1
- package/src/channels/discord.ts +189 -11
- package/src/channels/types.ts +90 -0
- package/src/channels/whatsapp.ts +13 -4
- package/src/cli.ts +9 -4
- package/src/cognition/agency/consequence.ts +122 -1
- package/src/cognition/agency/conversation.aim.ts +260 -0
- package/src/cognition/agency/engines/action.selector.ts +83 -2
- package/src/cognition/agency/engines/affordance.synthesizer.ts +90 -1
- package/src/cognition/agency/engines/motor.schema.executor.ts +152 -10
- package/src/cognition/agency/engines/reafference.engine.ts +117 -0
- package/src/cognition/agency/proactive.communicator.ts +19 -3
- package/src/cognition/agency/restart.ts +66 -0
- package/src/cognition/agency/selection.scoring.ts +33 -0
- package/src/cognition/agency/types.ts +35 -0
- package/src/cognition/cache/composition.ts +232 -0
- package/src/cognition/cache/deliberation.cache.ts +219 -0
- package/src/cognition/cache/fingerprint.ts +120 -0
- package/src/cognition/cache/types.ts +105 -0
- package/src/cognition/config.mirror.entities.ts +108 -0
- package/src/cognition/event.schemas.ts +22 -0
- package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
- package/src/cognition/faculties/episodic.consolidator.ts +59 -3
- package/src/cognition/faculties/executive.engine/commands.ts +189 -14
- package/src/cognition/faculties/executive.engine/context.ts +67 -13
- package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
- package/src/cognition/faculties/executive.engine/engine.ts +552 -131
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +162 -44
- package/src/cognition/faculties/executive.engine/facet.supervisor.ts +310 -65
- package/src/cognition/faculties/executive.engine/facet.ts +81 -26
- package/src/cognition/faculties/executive.engine/gating.ts +14 -14
- package/src/cognition/faculties/executive.engine/parser.ts +21 -1
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +167 -19
- package/src/cognition/faculties/executive.engine/types.ts +69 -0
- package/src/cognition/faculties/goal.manager.ts +94 -14
- package/src/cognition/faculties/known.entity.tracker.ts +267 -28
- package/src/cognition/faculties/moral.evaluator.ts +8 -3
- package/src/cognition/faculties/persona.consolidator.ts +141 -0
- package/src/cognition/faculties/reputation.tracker.ts +66 -2
- package/src/cognition/faculties/self.model.updater.ts +19 -12
- package/src/cognition/faculties/social.perception.ts +47 -3
- package/src/cognition/faculties/threat.evaluator.ts +7 -0
- package/src/cognition/faculties/working.memory.ts +10 -20
- package/src/cognition/identity.entity.ts +205 -0
- package/src/cognition/index.ts +7 -0
- package/src/cognition/memory/vector.adapter.ts +12 -3
- package/src/cognition/memory/vector.embedder.ts +45 -2
- package/src/cognition/persona.prior.ts +6 -0
- package/src/cognition/senses/audition.engine/engine.ts +404 -46
- package/src/cognition/senses/base.sense.engine.ts +1 -1
- package/src/cognition/senses/index.ts +12 -0
- package/src/cognition/social.identity.ts +273 -0
- package/src/cognition/utilities/token.tracker.ts +58 -5
- package/src/core/orchestrator.ts +38 -0
- package/src/llm/index.ts +25 -8
- package/src/llm/routing.ts +6 -0
- package/src/llm/summarizer.ts +1 -1
- package/src/llm/wire.contracts.ts +19 -0
- package/src/pma/index.ts +67 -53
- package/src/sdk/will.ts +39 -6
- package/src/stem/assembly.audit.ts +1 -0
- package/src/stem/guards/identity.coherence.ts +1 -1
- package/src/stem/index.ts +79 -2
- package/src/stem/mind.ts +172 -55
- package/src/stem/tracts/outbox.writer.ts +40 -2
- package/src/cognition/faculties/executive.engine/messages.ts +0 -102
|
@@ -63,6 +63,18 @@ export interface TextMessage {
|
|
|
63
63
|
content: string
|
|
64
64
|
/** Display name — used in the facet focus content. */
|
|
65
65
|
speakerName?: string
|
|
66
|
+
/**
|
|
67
|
+
* True when `threadId` is a PRIVATE thread — this someone and the mind, nobody
|
|
68
|
+
* else listening. The single fact that decides whether a room is the right
|
|
69
|
+
* place for a given utterance, and the Discord edge has always computed it
|
|
70
|
+
* (`isDM`) and discarded it before the mind could see it: a follow-up promised
|
|
71
|
+
* in a DM went out to a public channel, because the roster's "where did I last
|
|
72
|
+
* see them" is a different question from "where did I promise this".
|
|
73
|
+
*
|
|
74
|
+
* Undefined means the channel did not say, which is honestly different from
|
|
75
|
+
* false — an unknown room is not known to be public.
|
|
76
|
+
*/
|
|
77
|
+
direct?: boolean
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
export interface VoiceChunk {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// src/cognition/social.identity.ts — what something IS, vs where to find it
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// A `keid` used to be minted by the transport: `discord:${author.id}`,
|
|
6
|
+
// `whatsapp:${userId}`. Identity WAS the address, and whichever channel spoke
|
|
7
|
+
// first won the right to name the person. Everything downstream inherited that:
|
|
8
|
+
// twenty-two files key off a keid, so the same human met on two channels was two
|
|
9
|
+
// people to the reputation tracker, the theory-of-mind model, the attachment
|
|
10
|
+
// bond and the PMA — with no way to notice, and no way to say so.
|
|
11
|
+
//
|
|
12
|
+
// It also made "how should I reach them?" unaskable. There was exactly one id
|
|
13
|
+
// and it WAS a route, so the question collapsed into a roster guess about where
|
|
14
|
+
// the person was last seen. Live, that sent a follow-up promised in a DM into a
|
|
15
|
+
// public channel, because the roster's last-seen answer is not the same question
|
|
16
|
+
// as "where did I promise this".
|
|
17
|
+
//
|
|
18
|
+
// So identity and route are separated here:
|
|
19
|
+
//
|
|
20
|
+
// ke:<opaque> — a referent. Never a route. The anchor everything hangs on.
|
|
21
|
+
// handle — a way that referent has been reachable, with the
|
|
22
|
+
// circumstances under which it worked.
|
|
23
|
+
//
|
|
24
|
+
// Deliberately NOT social-only. `keid` has always stood for *known entity* id and
|
|
25
|
+
// the dossier has carried `kind: 'sentient' | 'thing'` since it shipped; the
|
|
26
|
+
// first cut of this minted `person:` ids, which was a narrower word than the
|
|
27
|
+
// system already used. A document, a repo, a dashboard, a room each have a what
|
|
28
|
+
// and a where, and the where can change while the what stays put.
|
|
29
|
+
//
|
|
30
|
+
// This is closer to how a person actually holds it. You know someone; you know
|
|
31
|
+
// places; and separately you know where you usually find whom. Three things that
|
|
32
|
+
// compose, not one contact record — which is why a handle carries evidence
|
|
33
|
+
// (`lastAnsweredTick`) rather than a priority number somebody configured.
|
|
34
|
+
//
|
|
35
|
+
// Everything here is pure and deterministic. `mintReferentId` hashes the first
|
|
36
|
+
// transport id ever seen for that referent, so a recorded run and its replay mint
|
|
37
|
+
// the same id (R2) — no clock, no counter, no RNG.
|
|
38
|
+
// ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
import type { Tick } from '#core/types'
|
|
41
|
+
import { fnv1a } from '#agency/consequence'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Marks an id as a referent rather than an address.
|
|
45
|
+
*
|
|
46
|
+
* `ke` for known-entity, which is what `keid` has always stood for — the
|
|
47
|
+
* vocabulary was never social. The first cut of this minted `person:` ids and
|
|
48
|
+
* that was a narrower word than the system uses: the dossier has carried
|
|
49
|
+
* `kind: 'sentient' | 'thing'` since it shipped, and the split here is not a
|
|
50
|
+
* social idea. It is referent vs. access path, which is how anything is held —
|
|
51
|
+
* you remember the book, and separately that it is on the shelf, on the Kindle,
|
|
52
|
+
* or at the library. A document, a repo, a dashboard, a room all have several
|
|
53
|
+
* routes that come and go while the thing itself stays put.
|
|
54
|
+
*/
|
|
55
|
+
export const REFERENT_PREFIX = 'ke:'
|
|
56
|
+
|
|
57
|
+
export const ALIAS_TYPE = 'known-entity-alias'
|
|
58
|
+
export const DOSSIER_TYPE = 'known-entity'
|
|
59
|
+
|
|
60
|
+
/** True for an anchor, false for a transport address (`discord:…`, `whatsapp:…`). */
|
|
61
|
+
export function isReferentId( id: string ): boolean {
|
|
62
|
+
return id.startsWith( REFERENT_PREFIX )
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mint the anchor for a referent first met at `seedKeid`.
|
|
67
|
+
*
|
|
68
|
+
* Kind is deliberately NOT in the id. It lives on the dossier, because kind is
|
|
69
|
+
* LEARNED and correctable — a handle you took for a bot turns out to be a person.
|
|
70
|
+
* Baked into the id, correcting it would mean re-identifying, and every faculty
|
|
71
|
+
* keyed off that id would lose its history of them at the moment it finally
|
|
72
|
+
* understood what they were.
|
|
73
|
+
*
|
|
74
|
+
* Deterministic by construction: the same first-seen transport id always yields
|
|
75
|
+
* the same referent id, so a replay of a recorded run mints identically and the
|
|
76
|
+
* state hashes match (R2). A counter would drift the moment two runs met people
|
|
77
|
+
* in a different order; a clock or RNG would never match at all.
|
|
78
|
+
*
|
|
79
|
+
* Opaque on purpose. The moment an id is readable as `ke:discord:123`
|
|
80
|
+
* something downstream starts parsing it back into a route, and the separation
|
|
81
|
+
* this whole module exists for quietly stops holding.
|
|
82
|
+
*/
|
|
83
|
+
export function mintReferentId( seedKeid: string ): string {
|
|
84
|
+
return `${ REFERENT_PREFIX }${ fnv1a( seedKeid ).toString( 36 ) }`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A way this referent has been reachable, and what happened there.
|
|
89
|
+
*
|
|
90
|
+
* `kind` is the one fact that decides whether a room is the right place for a
|
|
91
|
+
* given utterance, and it was being computed at the Discord edge (`isDM`) and
|
|
92
|
+
* discarded before the mind could see it.
|
|
93
|
+
*
|
|
94
|
+
* `lastAnsweredTick` is evidence, not configuration — it arrives free from the
|
|
95
|
+
* `social.responsiveness` signal. It is what lets the mind prefer the DM because
|
|
96
|
+
* that is where this person actually answers, rather than because a constant in
|
|
97
|
+
* the code says DMs rank higher.
|
|
98
|
+
*/
|
|
99
|
+
export interface Handle {
|
|
100
|
+
/** The transport address — what a channel bridge can actually deliver to. */
|
|
101
|
+
keid: string
|
|
102
|
+
/**
|
|
103
|
+
* 'dm' — a private thread. 'room' — somewhere others are listening. Left open
|
|
104
|
+
* for a non-social referent, where the meaningful distinction is a different one.
|
|
105
|
+
*/
|
|
106
|
+
kind: 'dm' | 'room' | 'unknown'
|
|
107
|
+
/** The place this handle lives in, once places are dossiers of their own. */
|
|
108
|
+
place?: string
|
|
109
|
+
/** When the mind last SAID something here. */
|
|
110
|
+
lastUsedTick?: Tick
|
|
111
|
+
/** When someone last answered it here — the only evidence that this route works. */
|
|
112
|
+
lastAnsweredTick?: Tick
|
|
113
|
+
/** Free-form, so a host can mark what its own vocabulary cares about. */
|
|
114
|
+
tags?: string[]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface EntityLike {
|
|
118
|
+
type: string
|
|
119
|
+
/** A frozen state entity may hand metadata over as either shape. */
|
|
120
|
+
metadata?: ReadonlyMap<string, unknown> | Record<string, unknown>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Normalize whichever shape metadata arrived in. */
|
|
124
|
+
function meta( e: EntityLike ): Record<string, unknown> {
|
|
125
|
+
const m = e.metadata
|
|
126
|
+
if( !m ) return {}
|
|
127
|
+
return m instanceof Map ? Object.fromEntries( m ) : m as Record<string, unknown>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function str( v: unknown ): string | undefined {
|
|
131
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* alias keid → canonical referent id.
|
|
136
|
+
*
|
|
137
|
+
* Every transport address a referent has been met at is an alias of its anchor,
|
|
138
|
+
* which is what lets the twenty-two keid consumers keep working untouched: they
|
|
139
|
+
* still see one opaque string per referent, it is simply no longer a route.
|
|
140
|
+
*/
|
|
141
|
+
export function readAliases( entities: ReadonlyMap<string, EntityLike> ): Map<string, string> {
|
|
142
|
+
const out = new Map<string, string>()
|
|
143
|
+
for( const [ , e ] of entities ){
|
|
144
|
+
if( e.type !== ALIAS_TYPE ) continue
|
|
145
|
+
const m = meta( e )
|
|
146
|
+
const a = str( m['aliasKeid'] )
|
|
147
|
+
const c = str( m['canonicalKeid'] )
|
|
148
|
+
if( a && c ) out.set( a, c )
|
|
149
|
+
}
|
|
150
|
+
return out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Follow an alias chain to the anchor. Cycle-safe; returns the input when unaliased. */
|
|
154
|
+
export function canonicalOf( aliases: ReadonlyMap<string, string>, keid: string ): string {
|
|
155
|
+
const seen = new Set<string>()
|
|
156
|
+
let id = keid
|
|
157
|
+
while( true ){
|
|
158
|
+
const next = aliases.get( id )
|
|
159
|
+
if( !next || next === id || seen.has( next ) ) return id
|
|
160
|
+
seen.add( id )
|
|
161
|
+
id = next
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve anything the mind might name — an anchor, a transport address, or a
|
|
167
|
+
* learned name — to the anchor.
|
|
168
|
+
*
|
|
169
|
+
* ONE resolver, because there were two and they disagreed:
|
|
170
|
+
* `extractKnownEntities` folded aliases (so the prompt showed one person) while
|
|
171
|
+
* `resolveKnownEntity` did not (so willing a reach-out to that same person could
|
|
172
|
+
* resolve to a keid that had been merged away, and the intention evaporated).
|
|
173
|
+
* Same question, two answers, in the same tick.
|
|
174
|
+
*
|
|
175
|
+
* Name matching stays last and exact. It is the weakest evidence here — two
|
|
176
|
+
* people genuinely can share a name — and the KnownEntityTracker's recognition
|
|
177
|
+
* pass already guards fusing them.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveKeid(
|
|
180
|
+
entities: ReadonlyMap<string, EntityLike>,
|
|
181
|
+
ref: string,
|
|
182
|
+
): string | undefined {
|
|
183
|
+
const needle = ref.trim().toLowerCase()
|
|
184
|
+
if( !needle ) return undefined
|
|
185
|
+
|
|
186
|
+
const aliases = readAliases( entities )
|
|
187
|
+
|
|
188
|
+
// An anchor or an address named directly.
|
|
189
|
+
const direct = canonicalOf( aliases, ref.trim() )
|
|
190
|
+
for( const [ , e ] of entities )
|
|
191
|
+
if( e.type === DOSSIER_TYPE && str( meta( e )['keid'] ) === direct ) return direct
|
|
192
|
+
|
|
193
|
+
// Otherwise a keid or a name, case-insensitively, in stable entity order.
|
|
194
|
+
for( const [ , e ] of entities ){
|
|
195
|
+
if( e.type !== DOSSIER_TYPE ) continue
|
|
196
|
+
const m = meta( e )
|
|
197
|
+
const keid = str( m['keid'] )
|
|
198
|
+
if( !keid ) continue
|
|
199
|
+
if( keid.toLowerCase() === needle ) return canonicalOf( aliases, keid )
|
|
200
|
+
if( str( m['name'] )?.toLowerCase() === needle ) return canonicalOf( aliases, keid )
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A known alias whose dossier was absorbed — still a real reference to someone.
|
|
204
|
+
for( const [ alias, canon ] of aliases )
|
|
205
|
+
if( alias.toLowerCase() === needle ) return canon
|
|
206
|
+
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The name the mind has learned for this referent, or undefined — never a placeholder. */
|
|
211
|
+
export function nameOf( entities: ReadonlyMap<string, EntityLike>, referentId: string ): string | undefined {
|
|
212
|
+
for( const [ , e ] of entities ){
|
|
213
|
+
if( e.type !== DOSSIER_TYPE || str( meta( e )['keid'] ) !== referentId ) continue
|
|
214
|
+
return str( meta( e )['name'] )?.trim() || undefined
|
|
215
|
+
}
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Every route the mind holds for this referent, most recently answered first. */
|
|
220
|
+
export function handlesOf( entities: ReadonlyMap<string, EntityLike>, referentId: string ): Handle[] {
|
|
221
|
+
for( const [ , e ] of entities ){
|
|
222
|
+
if( e.type !== DOSSIER_TYPE || str( meta( e )['keid'] ) !== referentId ) continue
|
|
223
|
+
const raw = meta( e )['handles']
|
|
224
|
+
if( !Array.isArray( raw ) ) return []
|
|
225
|
+
return ( raw as Handle[] )
|
|
226
|
+
.filter( h => h && typeof h.keid === 'string' )
|
|
227
|
+
.sort( ( a, b ) =>
|
|
228
|
+
( b.lastAnsweredTick ?? -1 ) - ( a.lastAnsweredTick ?? -1 )
|
|
229
|
+
|| ( b.lastUsedTick ?? -1 ) - ( a.lastUsedTick ?? -1 )
|
|
230
|
+
|| ( a.keid < b.keid ? -1 : a.keid > b.keid ? 1 : 0 ) )
|
|
231
|
+
}
|
|
232
|
+
return []
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The route to use when the mind has expressed no preference.
|
|
237
|
+
*
|
|
238
|
+
* A DEFAULT, not a decision. Which room to speak in is the mind's call, made
|
|
239
|
+
* from the circumstances it can now see; this only answers "nothing was chosen
|
|
240
|
+
* and the words must still go somewhere" — the case where the alternative is
|
|
241
|
+
* dropping the message.
|
|
242
|
+
*
|
|
243
|
+
* Ordering is evidence-first: somewhere this person has actually answered beats
|
|
244
|
+
* somewhere they have not, and a DM beats a room only as a tiebreak. A live Will
|
|
245
|
+
* lost a promised follow-up into a public channel precisely because its fallback
|
|
246
|
+
* ranked "where I last saw them" above "where they talk to me".
|
|
247
|
+
*/
|
|
248
|
+
export function defaultHandle( handles: readonly Handle[] ): Handle | undefined {
|
|
249
|
+
if( handles.length === 0 ) return undefined
|
|
250
|
+
const answered = handles.filter( h => h.lastAnsweredTick !== undefined )
|
|
251
|
+
const pool = answered.length > 0 ? answered : handles
|
|
252
|
+
return pool.find( h => h.kind === 'dm') ?? pool[0]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Fold a newly-seen address into a person's handle list.
|
|
257
|
+
*
|
|
258
|
+
* Merges rather than appends: meeting someone again in a room already known is
|
|
259
|
+
* not a new way to reach them, it is news about an existing one. Pure — returns
|
|
260
|
+
* a fresh array, so a caller writing it back through `setEntity` cannot
|
|
261
|
+
* accidentally share a mutable reference with frozen state.
|
|
262
|
+
*/
|
|
263
|
+
export function withHandle( handles: readonly Handle[], next: Handle ): Handle[] {
|
|
264
|
+
const out = handles.filter( h => h.keid !== next.keid )
|
|
265
|
+
const old = handles.find( h => h.keid === next.keid )
|
|
266
|
+
out.push( old ? { ...old, ...next,
|
|
267
|
+
// Never let a fresh sighting erase evidence the old record already held.
|
|
268
|
+
lastUsedTick: next.lastUsedTick ?? old.lastUsedTick,
|
|
269
|
+
lastAnsweredTick: next.lastAnsweredTick ?? old.lastAnsweredTick,
|
|
270
|
+
tags: [ ...new Set([ ...( old.tags ?? [] ), ...( next.tags ?? [] ) ]) ],
|
|
271
|
+
} : next )
|
|
272
|
+
return out.sort( ( a, b ) => ( a.keid < b.keid ? -1 : a.keid > b.keid ? 1 : 0 ) )
|
|
273
|
+
}
|
|
@@ -54,10 +54,15 @@ export type LLMCallAttribute =
|
|
|
54
54
|
| 'memory' // consolidation / embedding
|
|
55
55
|
| 'guard' // a safety reviewer
|
|
56
56
|
|
|
57
|
-
/** The specific cognitive
|
|
58
|
-
export type
|
|
57
|
+
/** The specific cognitive process being paid for. */
|
|
58
|
+
export type LLMCallProcess =
|
|
59
|
+
| 'cog' // background cogs' call
|
|
59
60
|
| 'decision' // the master's fused decision call
|
|
60
61
|
| 'ideation' // the deliberate path's propose pass
|
|
62
|
+
|
|
63
|
+
/** The specific cognitive function being paid for. */
|
|
64
|
+
export type LLMCallFunction =
|
|
65
|
+
| '-' // no-specific function
|
|
61
66
|
| 'deliberation' // action choice under contest
|
|
62
67
|
| 'conversation' // a live reply
|
|
63
68
|
| 'outreach' // an unprompted message
|
|
@@ -176,9 +181,25 @@ export interface TokenUsage {
|
|
|
176
181
|
*/
|
|
177
182
|
priced: boolean
|
|
178
183
|
|
|
184
|
+
/**
|
|
185
|
+
* How much this call demanded, 0..1 — the cognitive measure the router saw.
|
|
186
|
+
*
|
|
187
|
+
* Recorded so routing can be ANSWERED rather than argued. Every call computes
|
|
188
|
+
* this, routes on it, and until now threw it away — which left questions like
|
|
189
|
+
* "is deliberation being rated by the tick's mood rather than the stakes of
|
|
190
|
+
* its own choice?" with no dataset at all.
|
|
191
|
+
*
|
|
192
|
+
* Absent means UNMEASURED, never zero. It must stay nullable all the way to
|
|
193
|
+
* storage: a call that never reported demand and a call that reported 0.0 are
|
|
194
|
+
* different facts, and collapsing them would put a floor of invented
|
|
195
|
+
* confidence under exactly the analysis this exists to enable.
|
|
196
|
+
*/
|
|
197
|
+
demand?: number
|
|
198
|
+
|
|
179
199
|
// ── 5-axis cost attribution ──────────────────────────────
|
|
180
200
|
category: LLMCallCategory
|
|
181
201
|
attribute: LLMCallAttribute
|
|
202
|
+
process: LLMCallProcess
|
|
182
203
|
function: LLMCallFunction
|
|
183
204
|
/** Optional specific id or namespace: facet id, entity id, model name. */
|
|
184
205
|
scope?: string
|
|
@@ -197,9 +218,17 @@ export interface TokenUsage {
|
|
|
197
218
|
/** What callers pass to {@link TokenTracker.recordUsage} — cost and label are derived. */
|
|
198
219
|
export type RecordUsageInput = Omit<TokenUsage, 'estimatedCostUsd' | 'label' | 'priced'> & { label?: string }
|
|
199
220
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Compose a stable, readable label from the attribution axes.
|
|
223
|
+
*
|
|
224
|
+
* `function` carries '-' when the call has no specific one (the master's own
|
|
225
|
+
* decision/ideation passes), so it is dropped rather than rendered — a label of
|
|
226
|
+
* `executive/master/-` names nothing. Process always shows: it is what
|
|
227
|
+
* distinguishes the propose pass from the decision it feeds.
|
|
228
|
+
*/
|
|
229
|
+
function composeLabel( m: { category: LLMCallCategory; attribute: LLMCallAttribute; process: LLMCallProcess; function: LLMCallFunction; scope?: string } ): string {
|
|
230
|
+
const base = [ m.category, m.attribute, m.process, m.function === '-' ? '' : m.function ]
|
|
231
|
+
.filter( Boolean ).join('/')
|
|
203
232
|
return m.scope ? `${base}#${m.scope}` : base
|
|
204
233
|
}
|
|
205
234
|
|
|
@@ -245,6 +274,12 @@ export class TokenTracker implements SimulationEngine {
|
|
|
245
274
|
private _categoryTokens = new Map<string, { prompt: number; completion: number }>()
|
|
246
275
|
private _functionCosts = new Map<string, number>()
|
|
247
276
|
private _functionTokens = new Map<string, { prompt: number; completion: number }>()
|
|
277
|
+
// Split out of the function axis: `decision` vs `ideation` is which PROCESS ran,
|
|
278
|
+
// not which function it served. Without its own bucket the deliberate path's
|
|
279
|
+
// propose pass became invisible in cost reporting — every master call landing in
|
|
280
|
+
// the '-' function bucket regardless of whether it deliberated.
|
|
281
|
+
private _processCosts = new Map<string, number>()
|
|
282
|
+
private _processTokens = new Map<string, { prompt: number; completion: number }>()
|
|
248
283
|
// Per-provider spend. The axis a host actually reconciles against invoices —
|
|
249
284
|
// "which vendor did we pay?" is not answerable from the model id once routing
|
|
250
285
|
// can reach one model through several of them.
|
|
@@ -320,6 +355,7 @@ export class TokenTracker implements SimulationEngine {
|
|
|
320
355
|
// Per-axis breakdowns — the repartition surface (category × function).
|
|
321
356
|
this._accumulate( this._categoryCosts, this._categoryTokens, full.category, full )
|
|
322
357
|
this._accumulate( this._functionCosts, this._functionTokens, full.function, full )
|
|
358
|
+
this._accumulate( this._processCosts, this._processTokens, full.process, full )
|
|
323
359
|
// Unattributed rather than guessed: a caller that did not say which
|
|
324
360
|
// provider served the call must not be silently folded into the default.
|
|
325
361
|
this._accumulate( this._providerCosts, this._providerTokens, full.provider ?? 'unattributed', full )
|
|
@@ -350,6 +386,7 @@ export class TokenTracker implements SimulationEngine {
|
|
|
350
386
|
provider: full.provider,
|
|
351
387
|
category: full.category,
|
|
352
388
|
attribute: full.attribute,
|
|
389
|
+
process: full.process,
|
|
353
390
|
function: full.function,
|
|
354
391
|
scope: full.scope,
|
|
355
392
|
label: full.label,
|
|
@@ -363,6 +400,9 @@ export class TokenTracker implements SimulationEngine {
|
|
|
363
400
|
// priced this model, NOT because the call was free — a consumer summing
|
|
364
401
|
// spend must not fold unpriced calls in as zero.
|
|
365
402
|
priced: full.priced,
|
|
403
|
+
// Undefined stays undefined — see TokenUsage.demand. A consumer that
|
|
404
|
+
// coerces this to 0 has silently invented a measurement.
|
|
405
|
+
demand: full.demand,
|
|
366
406
|
latencyMs: full.latencyMs,
|
|
367
407
|
}
|
|
368
408
|
|
|
@@ -461,6 +501,7 @@ export class TokenTracker implements SimulationEngine {
|
|
|
461
501
|
totalCost: this._totalCost,
|
|
462
502
|
categoryBreakdown: Object.fromEntries( this._categoryCosts ),
|
|
463
503
|
functionBreakdown: Object.fromEntries( this._functionCosts ),
|
|
504
|
+
processBreakdown: Object.fromEntries( this._processCosts ),
|
|
464
505
|
},
|
|
465
506
|
})
|
|
466
507
|
}
|
|
@@ -502,6 +543,16 @@ export class TokenTracker implements SimulationEngine {
|
|
|
502
543
|
return this._functionTokens
|
|
503
544
|
}
|
|
504
545
|
|
|
546
|
+
/** Cost broken down by process ('decision' | 'ideation' | 'cog'). */
|
|
547
|
+
get processBreakdown(): ReadonlyMap<string, number> {
|
|
548
|
+
return this._processCosts
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Token counts (prompt + completion) broken down by process. */
|
|
552
|
+
get processTokenBreakdown(): ReadonlyMap<string, { prompt: number; completion: number }> {
|
|
553
|
+
return this._processTokens
|
|
554
|
+
}
|
|
555
|
+
|
|
505
556
|
/**
|
|
506
557
|
* Cost broken down by provider ('anthropic' | 'glm' | 'moonshot' | …), plus
|
|
507
558
|
* an `unattributed` bucket for usage recorded without one.
|
|
@@ -554,6 +605,8 @@ export class TokenTracker implements SimulationEngine {
|
|
|
554
605
|
this._categoryTokens.clear()
|
|
555
606
|
this._functionCosts.clear()
|
|
556
607
|
this._functionTokens.clear()
|
|
608
|
+
this._processCosts.clear()
|
|
609
|
+
this._processTokens.clear()
|
|
557
610
|
this._providerCosts.clear()
|
|
558
611
|
this._providerTokens.clear()
|
|
559
612
|
this._tickCosts = []
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -62,6 +62,13 @@ export interface SimulationEngine {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Fraction of one tick interval a single non-async engine may consume before the
|
|
67
|
+
* orchestrator says so. Half a tick is generous for work that is meant to be
|
|
68
|
+
* in-memory, and low enough to fire long before tick-denominated deadlines drift.
|
|
69
|
+
*/
|
|
70
|
+
const SLOW_ENGINE_FRACTION = 0.5
|
|
71
|
+
|
|
65
72
|
// ── Middleware ───────────────────────────────────────────────
|
|
66
73
|
|
|
67
74
|
export type TickMiddleware = (
|
|
@@ -384,6 +391,35 @@ export class DefaultOrchestrator implements Orchestrator {
|
|
|
384
391
|
* calls _executeTick() which handles pause/stop checks internally.
|
|
385
392
|
* The orchestrator is the sole driver of ticks.
|
|
386
393
|
*/
|
|
394
|
+
/**
|
|
395
|
+
* A plain SimulationEngine's `react()` is implicitly "finish inside the tick" —
|
|
396
|
+
* only an AsyncEngine is allowed to span ticks, and it does so by LAUNCHING work
|
|
397
|
+
* and landing it later (see async.engine.ts: "react() never awaits LLM calls").
|
|
398
|
+
* Nothing enforces that on everyone else, and the failure is silent and severe:
|
|
399
|
+
* every agency deadline is denominated in TICKS, so an engine that awaits network
|
|
400
|
+
* I/O does not merely run slowly, it rescales time for the whole mind.
|
|
401
|
+
*
|
|
402
|
+
* Measured: one rate-limited embedding call awaited inside EpisodicConsolidator
|
|
403
|
+
* made two consecutive ticks take 64.9s and 63.5s. `AWAIT_TIMEOUT` — 15 ticks,
|
|
404
|
+
* normally ~15s — silently became 15 minutes, so a communicate intent sat
|
|
405
|
+
* 'awaiting' forever and the serial selector never chose anything again. 45
|
|
406
|
+
* executive decisions produced one intent and zero delivered messages, with no
|
|
407
|
+
* error anywhere. This turns that into a line in the log the first time it happens.
|
|
408
|
+
*/
|
|
409
|
+
private _warnIfSlow( engine: SimulationEngine, elapsedMs: number ): void {
|
|
410
|
+
// AsyncEngines are exempt by design — spanning ticks is their contract.
|
|
411
|
+
if( ( engine as { hasPendingWork?: unknown } ).hasPendingWork !== undefined ) return
|
|
412
|
+
|
|
413
|
+
const budget = ( this._config.tickIntervalMs ?? 1000 ) * SLOW_ENGINE_FRACTION
|
|
414
|
+
if( elapsedMs <= budget ) return
|
|
415
|
+
|
|
416
|
+
logger.warn(
|
|
417
|
+
`[Orchestrator] Engine "${ engine.name }" held the tick for ${ Math.round( elapsedMs ) }ms ` +
|
|
418
|
+
`at tick ${ this._currentTick } (budget ${ Math.round( budget ) }ms). A non-async engine must not ` +
|
|
419
|
+
`await network I/O — tick-denominated deadlines elsewhere are being stretched by this.`
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
387
423
|
private _runLoop( context: SimulationContext ): void {
|
|
388
424
|
if( this._tickTimer ) return
|
|
389
425
|
|
|
@@ -478,6 +514,7 @@ export class DefaultOrchestrator implements Orchestrator {
|
|
|
478
514
|
const allResults: EngineResult[] = []
|
|
479
515
|
|
|
480
516
|
const runEngine = async ( engine: SimulationEngine ): Promise<void> => {
|
|
517
|
+
const startedAt = wallClock()
|
|
481
518
|
try {
|
|
482
519
|
const result = await engine.react?.( this._clock.delta, this._currentTick, snapshot, engineContext )
|
|
483
520
|
if( result ) allResults.push( result )
|
|
@@ -494,6 +531,7 @@ export class DefaultOrchestrator implements Orchestrator {
|
|
|
494
531
|
)
|
|
495
532
|
}
|
|
496
533
|
}
|
|
534
|
+
finally { this._warnIfSlow( engine, wallClock() - startedAt ) }
|
|
497
535
|
}
|
|
498
536
|
|
|
499
537
|
for( const engine of this._enginesTick() )
|
package/src/llm/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { SessionLogger } from '#stem/tracts/session.logger'
|
|
|
8
8
|
import { writeFileSync, mkdirSync } from 'node:fs'
|
|
9
9
|
import type {
|
|
10
10
|
TokenTracker, LLMCallCategory, LLMCallAttribute, LLMCallFunction,
|
|
11
|
+
LLMCallProcess,
|
|
11
12
|
} from '#cognition/utilities/token.tracker'
|
|
12
13
|
import { type ModelRouter, isNullRouter } from '#llm/routing'
|
|
13
14
|
import { getCompletionRecorder, getCompletionSource } from '#core/completion.recorder'
|
|
@@ -275,6 +276,8 @@ export interface LLMCallMeta {
|
|
|
275
276
|
/** The actor/subsystem doing the work. */
|
|
276
277
|
attribute: LLMCallAttribute
|
|
277
278
|
/** The specific cognitive function. */
|
|
279
|
+
process: LLMCallProcess
|
|
280
|
+
/** The specific cognitive function. */
|
|
278
281
|
function: LLMCallFunction
|
|
279
282
|
/** Optional specific id or namespace: facet id, entity id, model name. */
|
|
280
283
|
scope?: string
|
|
@@ -284,13 +287,23 @@ export interface LLMCallMeta {
|
|
|
284
287
|
* How much this call demands, 0..1 — MODEL_ROUTING W0.
|
|
285
288
|
*
|
|
286
289
|
* A *cognitive* measure, never a commercial one: it says how consequential or
|
|
287
|
-
* uncertain this moment is, never who is paying for it.
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* the
|
|
292
|
-
*
|
|
293
|
-
*
|
|
290
|
+
* uncertain this moment is, never who is paying for it.
|
|
291
|
+
*
|
|
292
|
+
* What actually reports it today:
|
|
293
|
+
* · the master and every facet — including the deliberation facet — forward
|
|
294
|
+
* `effortScore`, the a-priori effort gate (uncertainty, prior confidence,
|
|
295
|
+
* novelty, a pending reply, stress load);
|
|
296
|
+
* · structurally background work (summarising, the identity guard) reports a
|
|
297
|
+
* low constant, because it is background whether the mind is calm or in
|
|
298
|
+
* crisis.
|
|
299
|
+
*
|
|
300
|
+
* Note what is NOT here: agency's `stakes( winner, bias )` — the consequence
|
|
301
|
+
* of the specific choice under contest — is computed in
|
|
302
|
+
* `agency/selection.scoring.ts` and never reaches a call. So a deliberation
|
|
303
|
+
* facet currently reports the tick's general effort, not the stakes of the
|
|
304
|
+
* choice it is deliberating. Carrying it is MODEL_ROUTING W1, still open.
|
|
305
|
+
* This comment previously claimed deliberation passed those stakes; it does
|
|
306
|
+
* not, and a doc comment that ships to npm is a bad place to be aspirational.
|
|
294
307
|
*
|
|
295
308
|
* Absent means UNKNOWN, not zero: a consumer must fall back to its default
|
|
296
309
|
* rather than treat a missing value as "cheapest possible".
|
|
@@ -312,7 +325,7 @@ export const BACKGROUND_DEMAND = 0.1
|
|
|
312
325
|
export const ESCALATION_DEMAND = 0.7
|
|
313
326
|
|
|
314
327
|
/** Default attribution when a caller does not tag itself (back-compat). */
|
|
315
|
-
const DEFAULT_CALL_META: LLMCallMeta = { category: 'executive', attribute: 'master',
|
|
328
|
+
const DEFAULT_CALL_META: LLMCallMeta = { category: 'executive', attribute: 'master', process: 'decision', function: '-' }
|
|
316
329
|
|
|
317
330
|
/**
|
|
318
331
|
* Fill in wire and base URL, or say clearly what is missing.
|
|
@@ -606,6 +619,9 @@ export class LLMDirector {
|
|
|
606
619
|
// reached from several vendors at very different prices.
|
|
607
620
|
model: ep.model,
|
|
608
621
|
provider: ep.provider,
|
|
622
|
+
// The router's own input, kept alongside its output. Without this the
|
|
623
|
+
// ledger records WHERE a call went and never WHY.
|
|
624
|
+
demand: meta.demand,
|
|
609
625
|
promptTokens: result.inputTok,
|
|
610
626
|
completionTokens: result.outputTok,
|
|
611
627
|
totalTokens: result.inputTok + result.outputTok,
|
|
@@ -613,6 +629,7 @@ export class LLMDirector {
|
|
|
613
629
|
cacheWriteTokens: result.cacheWriteTok,
|
|
614
630
|
category: meta.category,
|
|
615
631
|
attribute: meta.attribute,
|
|
632
|
+
process: meta.process,
|
|
616
633
|
function: meta.function,
|
|
617
634
|
scope: meta.scope,
|
|
618
635
|
label: meta.label,
|
package/src/llm/routing.ts
CHANGED
|
@@ -99,6 +99,8 @@ export interface RoutingRule {
|
|
|
99
99
|
category?: LLMCallMeta['category']
|
|
100
100
|
/** Match `LLMCallMeta.attribute` exactly (e.g. 'master', 'facet', 'guard'). */
|
|
101
101
|
attribute?: LLMCallMeta['attribute']
|
|
102
|
+
/** Match `LLMCallMeta.process` exactly (e.g. 'decision', 'ideation'). */
|
|
103
|
+
process?: LLMCallMeta['process']
|
|
102
104
|
/** Match `LLMCallMeta.function` exactly (e.g. 'decision', 'consolidation'). */
|
|
103
105
|
function?: LLMCallMeta['function']
|
|
104
106
|
/**
|
|
@@ -182,6 +184,10 @@ export function chainRouters( ...routers: ( ModelRouter | null | undefined )[] )
|
|
|
182
184
|
function matches( rule: RoutingRule, meta: LLMCallMeta ): boolean {
|
|
183
185
|
if( rule.category !== undefined && rule.category !== meta.category ) return false
|
|
184
186
|
if( rule.attribute !== undefined && rule.attribute !== meta.attribute ) return false
|
|
187
|
+
// `process` split out of `function` (ideation vs decision). It was declarable on a
|
|
188
|
+
// rule from the moment the axis existed but never tested here, so a rule that named
|
|
189
|
+
// it matched every process — the opposite of the AND semantics every other axis has.
|
|
190
|
+
if( rule.process !== undefined && rule.process !== meta.process ) return false
|
|
185
191
|
if( rule.function !== undefined && rule.function !== meta.function ) return false
|
|
186
192
|
|
|
187
193
|
// Absent demand is UNKNOWN, not zero: a demand-bounded rule cannot claim a
|
package/src/llm/summarizer.ts
CHANGED
|
@@ -152,7 +152,7 @@ export class ExecutiveSummarizer {
|
|
|
152
152
|
// MODEL_ROUTING W0 — compression is background work at a constant low
|
|
153
153
|
// demand: distilling excerpts is the same job whether the mind is calm
|
|
154
154
|
// or in crisis, so there is no honest per-tick measure to forward here.
|
|
155
|
-
{ category: 'summarizer', attribute: 'memory', function: 'consolidation', demand: BACKGROUND_DEMAND }
|
|
155
|
+
{ category: 'summarizer', attribute: 'memory', process: 'cog', function: 'consolidation', demand: BACKGROUND_DEMAND }
|
|
156
156
|
)
|
|
157
157
|
|
|
158
158
|
if( result.text ){
|
|
@@ -34,6 +34,25 @@ export const REPLY_TEXT_TAG = 'REPLY_TEXT'
|
|
|
34
34
|
export const REPLY_TEXT_OPEN = `[${REPLY_TEXT_TAG}]`
|
|
35
35
|
export const REPLY_TEXT_CLOSE = `[/${REPLY_TEXT_TAG}]`
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The block a facet uses to say it has decided NOT to speak, and why.
|
|
39
|
+
*
|
|
40
|
+
* "Omit [REPLY_TEXT] to stay silent" was stated in the prompt and nowhere else —
|
|
41
|
+
* a rule with no mechanism behind it. Live, an outreach facet read that it had
|
|
42
|
+
* three unanswered messages outstanding, correctly concluded there was nothing new
|
|
43
|
+
* to say, and — having no other block to put that sentence in — wrote it into
|
|
44
|
+
* REPLY_TEXT. "— nothing new to say to FKEM. Three messages unanswered is enough."
|
|
45
|
+
* was delivered to FKEM, in the third person, about him.
|
|
46
|
+
*
|
|
47
|
+
* The prompt already warned against exactly that. Warning was not enough, because
|
|
48
|
+
* the pressure is structural: a mind that decides not to speak still has something
|
|
49
|
+
* to say about the decision, and REPLY_TEXT was the only place to say it. This is
|
|
50
|
+
* the place. Content here is recorded and never sent.
|
|
51
|
+
*/
|
|
52
|
+
export const NO_MESSAGE_TAG = 'NO_MESSAGE'
|
|
53
|
+
export const NO_MESSAGE_OPEN = `[${NO_MESSAGE_TAG}]`
|
|
54
|
+
export const NO_MESSAGE_CLOSE = `[/${NO_MESSAGE_TAG}]`
|
|
55
|
+
|
|
37
56
|
/** Wrap a reply body in the block markers (the mock's emission shape). */
|
|
38
57
|
export function wrapReplyText( body: string ): string {
|
|
39
58
|
return [ REPLY_TEXT_OPEN, body, REPLY_TEXT_CLOSE ].join('\n')
|