@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.
Files changed (79) hide show
  1. package/dist/channels/discord.d.ts +67 -6
  2. package/dist/channels/discord.js +112 -6
  3. package/dist/channels/discord.js.map +1 -1
  4. package/dist/channels/whatsapp.d.ts +1 -1
  5. package/dist/channels/whatsapp.js +4 -1
  6. package/dist/channels/whatsapp.js.map +1 -1
  7. package/dist/cli.js +3174 -867
  8. package/dist/cli.js.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +3236 -1042
  11. package/dist/index.js.map +1 -1
  12. package/dist/mcp/effectors.d.ts +1 -1
  13. package/dist/{will-cS6k4uiJ.d.ts → will-DbDj_TEH.d.ts} +752 -17
  14. package/package.json +1 -1
  15. package/src/channels/discord.ts +189 -11
  16. package/src/channels/types.ts +90 -0
  17. package/src/channels/whatsapp.ts +13 -4
  18. package/src/cli.ts +9 -4
  19. package/src/cognition/agency/consequence.ts +122 -1
  20. package/src/cognition/agency/conversation.aim.ts +260 -0
  21. package/src/cognition/agency/engines/action.selector.ts +83 -2
  22. package/src/cognition/agency/engines/affordance.synthesizer.ts +90 -1
  23. package/src/cognition/agency/engines/motor.schema.executor.ts +152 -10
  24. package/src/cognition/agency/engines/reafference.engine.ts +117 -0
  25. package/src/cognition/agency/proactive.communicator.ts +19 -3
  26. package/src/cognition/agency/restart.ts +66 -0
  27. package/src/cognition/agency/selection.scoring.ts +33 -0
  28. package/src/cognition/agency/types.ts +35 -0
  29. package/src/cognition/cache/composition.ts +232 -0
  30. package/src/cognition/cache/deliberation.cache.ts +219 -0
  31. package/src/cognition/cache/fingerprint.ts +120 -0
  32. package/src/cognition/cache/types.ts +105 -0
  33. package/src/cognition/config.mirror.entities.ts +108 -0
  34. package/src/cognition/event.schemas.ts +22 -0
  35. package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
  36. package/src/cognition/faculties/episodic.consolidator.ts +59 -3
  37. package/src/cognition/faculties/executive.engine/commands.ts +189 -14
  38. package/src/cognition/faculties/executive.engine/context.ts +67 -13
  39. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
  40. package/src/cognition/faculties/executive.engine/engine.ts +552 -131
  41. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +162 -44
  42. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +310 -65
  43. package/src/cognition/faculties/executive.engine/facet.ts +81 -26
  44. package/src/cognition/faculties/executive.engine/gating.ts +14 -14
  45. package/src/cognition/faculties/executive.engine/parser.ts +21 -1
  46. package/src/cognition/faculties/executive.engine/prompt.factory.ts +167 -19
  47. package/src/cognition/faculties/executive.engine/types.ts +69 -0
  48. package/src/cognition/faculties/goal.manager.ts +94 -14
  49. package/src/cognition/faculties/known.entity.tracker.ts +267 -28
  50. package/src/cognition/faculties/moral.evaluator.ts +8 -3
  51. package/src/cognition/faculties/persona.consolidator.ts +141 -0
  52. package/src/cognition/faculties/reputation.tracker.ts +66 -2
  53. package/src/cognition/faculties/self.model.updater.ts +19 -12
  54. package/src/cognition/faculties/social.perception.ts +47 -3
  55. package/src/cognition/faculties/threat.evaluator.ts +7 -0
  56. package/src/cognition/faculties/working.memory.ts +10 -20
  57. package/src/cognition/identity.entity.ts +205 -0
  58. package/src/cognition/index.ts +7 -0
  59. package/src/cognition/memory/vector.adapter.ts +12 -3
  60. package/src/cognition/memory/vector.embedder.ts +45 -2
  61. package/src/cognition/persona.prior.ts +6 -0
  62. package/src/cognition/senses/audition.engine/engine.ts +404 -46
  63. package/src/cognition/senses/base.sense.engine.ts +1 -1
  64. package/src/cognition/senses/index.ts +12 -0
  65. package/src/cognition/social.identity.ts +273 -0
  66. package/src/cognition/utilities/token.tracker.ts +58 -5
  67. package/src/core/orchestrator.ts +38 -0
  68. package/src/llm/index.ts +25 -8
  69. package/src/llm/routing.ts +6 -0
  70. package/src/llm/summarizer.ts +1 -1
  71. package/src/llm/wire.contracts.ts +19 -0
  72. package/src/pma/index.ts +67 -53
  73. package/src/sdk/will.ts +39 -6
  74. package/src/stem/assembly.audit.ts +1 -0
  75. package/src/stem/guards/identity.coherence.ts +1 -1
  76. package/src/stem/index.ts +79 -2
  77. package/src/stem/mind.ts +172 -55
  78. package/src/stem/tracts/outbox.writer.ts +40 -2
  79. 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 function being paid for. */
58
- export type LLMCallFunction =
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
- /** Compose a stable, readable label from the attribution axes. */
201
- function composeLabel( m: { category: LLMCallCategory; attribute: LLMCallAttribute; function: LLMCallFunction; scope?: string } ): string {
202
- const base = `${m.category}/${m.attribute}/${m.function}`
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 = []
@@ -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. Two faculties already
288
- * compute it and simply forward what they have — the master and its facets
289
- * pass `effortScore` (the a-priori effort gate: uncertainty, prior
290
- * confidence, novelty, a pending reply, stress load), and deliberation passes
291
- * the agency stakes of the choice under consideration. Structurally
292
- * background work (summarising, guarding, embedding, delivery) reports a low
293
- * constant, because it is background whether the mind is calm or in crisis.
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', function: 'decision' }
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,
@@ -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
@@ -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')