@mindot/will 0.7.0 → 0.8.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 (35) hide show
  1. package/README.md +87 -22
  2. package/dist/channels/discord.d.ts +1 -1
  3. package/dist/channels/whatsapp.d.ts +1 -1
  4. package/dist/cli.js +10823 -10454
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +562 -226
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp/effectors.d.ts +1 -1
  10. package/dist/{will-DAW0l-lY.d.ts → will-cS6k4uiJ.d.ts} +470 -84
  11. package/package.json +1 -1
  12. package/src/cognition/agency/engines/action.selector.ts +2 -1
  13. package/src/cognition/agency/engines/reafference.engine.ts +12 -2
  14. package/src/cognition/agency/reconcile.learning.ts +16 -2
  15. package/src/cognition/agency/schemas/repertoire.ts +12 -5
  16. package/src/cognition/config.mirror.entities.ts +1 -1
  17. package/src/cognition/faculties/executive.engine/engine.ts +136 -58
  18. package/src/cognition/faculties/executive.engine/facet.ts +10 -2
  19. package/src/cognition/faculties/executive.engine/prompt.factory.ts +2 -1
  20. package/src/cognition/index.ts +4 -0
  21. package/src/cognition/memory/vector.embedder.ts +9 -5
  22. package/src/cognition/utilities/token.tracker.ts +191 -96
  23. package/src/host/boot.ts +78 -22
  24. package/src/index.ts +35 -0
  25. package/src/llm/index.ts +397 -96
  26. package/src/llm/routing.ts +198 -0
  27. package/src/llm/summarizer.ts +5 -1
  28. package/src/runners/thin-shim.runner.ts +18 -6
  29. package/src/sdk/will.ts +82 -16
  30. package/src/stem/guards/identity.coherence.ts +17 -6
  31. package/src/stem/index.ts +3 -3
  32. package/src/stem/mind.ts +155 -24
  33. package/src/stem/policy/arbiter.ts +49 -14
  34. package/src/stem/policy/rule.table.ts +2 -2
  35. package/src/stem/tracts/effector.controller.ts +56 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindot/will",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "author": "Fabrice <fabrice8@github.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,6 +38,7 @@ import {
38
38
  import { readEffectiveParams, readPersonaPrior } from '#cognition/persona.prior'
39
39
  import { RUPTURE_REVOKE_GATE, revocationEntity } from '#agency/revocation'
40
40
  import { liveConsequences, matchConsequenceText } from '#agency/consequence'
41
+ import { asFinality } from '#stem/policy/arbiter'
41
42
 
42
43
  /**
43
44
  * Activation margin (winner − runner-up) below which the choice is "contested" —
@@ -657,7 +658,7 @@ function refusedClassSchemas( state: ReadonlySimulationState ): Set<string> {
657
658
  for( const e of state.entities.values() ){
658
659
  if( e.type !== 'agency.outcome') continue
659
660
  const m = e.metadata
660
- if( m?.['refused'] !== true || str( m?.['finality'] ) !== 'class') continue
661
+ if( m?.['refused'] !== true || asFinality( m?.['finality'] ) !== 'class') continue
661
662
  const schema = str( m?.['schema'] )
662
663
  if( schema ) out.add( schema )
663
664
  }
@@ -29,6 +29,7 @@ import type { CognitiveEngine, EngineResult } from '#cognition/types'
29
29
  import type { CognitiveEventSchema } from '#cognition/schema.registry'
30
30
  import type { SchemaRepertoire } from '#agency/schemas/repertoire'
31
31
  import { schemaEntityId, availabilityEntityId } from '#agency/schemas/repertoire'
32
+ import { asFinality } from '#stem/policy/arbiter'
32
33
  import { AWAIT_TIMEOUT } from '#agency/engines/motor.schema.executor'
33
34
 
34
35
  const PROC_THRESHOLD = 0.60 // mirror of repertoire's threshold for the habitual-count metric
@@ -201,8 +202,17 @@ export class ReafferenceEngine implements CognitiveEngine {
201
202
  // is merely forbidden to do. The awaiting intent is still freed, and a
202
203
  // refused plan step is signalled unsuccessful so the plan doesn't hang.
203
204
  if( m['refused'] === true ){
204
- const finality = str( m['finality'] ) === 'class' ? 'class' : 'instance'
205
- this._repertoire.recordRefusal( schema, finality, tick )
205
+ const finality = asFinality( m['finality'] )
206
+
207
+ // P5 — 'context' means the refusal was NOT ABOUT THE ACTION (tainted
208
+ // context, an unavailable dependency, the arbiter itself down). Denting
209
+ // availability here would teach a lesson the policy never taught:
210
+ // "reach for this less", when nothing about the ability was the problem.
211
+ // So the intent is freed and the plan step signalled — the mind must not
212
+ // hang — and NOTHING else is written. Not competence, not availability.
213
+ if( finality !== 'context')
214
+ this._repertoire.recordRefusal( schema, finality, tick )
215
+
206
216
  if( fromState ) del.push( id )
207
217
  const refusedIntent = str( m['intentId'] )
208
218
  if( refusedIntent ) del.push( refusedIntent )
@@ -16,6 +16,7 @@
16
16
  // ─────────────────────────────────────────────────────────────
17
17
 
18
18
  import type { EntityInput, Tick } from '#core/types'
19
+ import type { DenialFinality, PolicyCounterfactual } from '#stem/policy/arbiter'
19
20
 
20
21
  export interface HostAckResult {
21
22
  success: boolean
@@ -31,7 +32,17 @@ export interface HostAckResult {
31
32
  * forbidden to do. `finality` decides how hard availability is cut.
32
33
  */
33
34
  refused?: boolean
34
- finality?: 'class' | 'instance'
35
+ finality?: DenialFinality
36
+ /**
37
+ * ENVELOPE_NARROWING P0 — what WOULD have been permitted, e.g.
38
+ * `{ field: 'amount', requested: 500, allowed: 100 }`.
39
+ *
40
+ * Carried onto the outcome so the tape and the thing the mind actually learns
41
+ * from agree about what happened. Until ENVELOPE_NARROWING P1 lands nothing
42
+ * READS it — that fold is blocked upstream on whether a scalar `allowed` is a
43
+ * ceiling or a floor — but dropping it here is what made the two disagree.
44
+ */
45
+ counterfactual?: PolicyCounterfactual
35
46
  }
36
47
 
37
48
  /**
@@ -79,7 +90,10 @@ export function reconcileInvocation(
79
90
  mode: 'external',
80
91
  tick,
81
92
  reconciled: true,
82
- ...( result.refused ? { refused: true, finality: result.finality ?? 'instance' } : {} ),
93
+ ...( result.refused ? { refused: true, finality: result.finality ?? 'parameter' } : {} ),
94
+ // Only when the arbiter actually reported a bound — a refusal without one
95
+ // writes no key at all, so the quiet path is unchanged.
96
+ ...( result.refused && result.counterfactual ? { counterfactual: result.counterfactual } : {} ),
83
97
  ...( provenance.planId ? { planId: provenance.planId } : {} ),
84
98
  ...( provenance.stepId ? { stepId: provenance.stepId } : {} ),
85
99
  },
@@ -26,6 +26,7 @@
26
26
 
27
27
  import type { MotorSchema, LearnedSkill } from '#agency/types'
28
28
  import type { EntityInput, ReadonlySimulationState } from '#core/types'
29
+ import type { DenialFinality } from '#stem/policy/arbiter'
29
30
  import { INNATE_SCHEMAS } from '#agency/schemas/innate'
30
31
 
31
32
  const VALUE_ALPHA = 0.2 // value EMA rate
@@ -50,8 +51,8 @@ const DROP_HABIT = 0.05 // below this (and learned) the skill is forgott
50
51
  // availability never floors at zero, and it climbs back toward 1 with disuse of
51
52
  // the refusal. P2 keys availability by SCHEMA; per-(schema, params) envelope
52
53
  // narrowing is a follow-up that belongs at selection time, not fielding time.
53
- const AVAIL_DROP_CLASS = 0.50 // multiplicative cut on a class-final refusal
54
- const AVAIL_DROP_INSTANCE = 0.12 // lighter cut on an instance-final refusal
54
+ const AVAIL_DROP_CLASS = 0.50 // multiplicative cut on a class-final refusal
55
+ const AVAIL_DROP_PARAMETER = 0.12 // lighter cut when only the arguments were refused
55
56
  const AVAIL_FLOOR = 0.05 // never zero — re-probe must always be possible
56
57
  const AVAIL_RECOVERY = 0.02 // per-decay climb back toward 1
57
58
  const AVAIL_RECOVERED = 0.999 // at/above this the entry is dropped (quiet path)
@@ -119,13 +120,19 @@ export class SchemaRepertoire {
119
120
 
120
121
  /**
121
122
  * Fold a policy refusal into the availability layer (NOT competence). A
122
- * `class` refusal cuts availability hard; an `instance` refusal dents it
123
+ * `class` refusal cuts availability hard; a `parameter` refusal dents it
123
124
  * lightly. Multiplicative so repeated refusals compound toward — but never
124
125
  * reach — zero, keeping re-probe alive.
126
+ *
127
+ * `context` is EXCLUDED FROM THE SIGNATURE, not handled inside: a refusal
128
+ * that was not about the action must never reach the availability layer at
129
+ * all, and making that a type error rather than a convention means a future
130
+ * caller cannot quietly re-introduce the dent. The routing decision lives in
131
+ * the ReafferenceEngine's refused branch (P5).
125
132
  */
126
- recordRefusal( schema: string, finality: 'class' | 'instance', tick: number ): number {
133
+ recordRefusal( schema: string, finality: Exclude<DenialFinality, 'context'>, tick: number ): number {
127
134
  const prev = this._availability.get( schema )?.value ?? 1
128
- const drop = finality === 'class' ? AVAIL_DROP_CLASS : AVAIL_DROP_INSTANCE
135
+ const drop = finality === 'class' ? AVAIL_DROP_CLASS : AVAIL_DROP_PARAMETER
129
136
  const value = Math.max( AVAIL_FLOOR, prev * ( 1 - drop ) )
130
137
  this._availability.set( schema, { value, lastRefusedTick: tick } )
131
138
  return value
@@ -34,7 +34,7 @@ export function buildEngineConfigEntities( config: WillConfig, executiveInterval
34
34
  engine: 'system',
35
35
  params: {
36
36
  anatomy: config.anatomy ?? 'mind',
37
- model: config.model ?? '',
37
+ model: config.llm?.model ?? '',
38
38
  tickIntervalMs: config.tickIntervalMs ?? 1000
39
39
  }
40
40
  },
@@ -60,7 +60,10 @@ import {
60
60
  type GatingState
61
61
  } from '#faculties/executive.engine/gating'
62
62
  import { LLMDirector } from '#llm/index'
63
- import { defaultModelFor, type LLMProvider } from '#llm/index'
63
+ import { MOCK_PROVIDER, MOCK_MODEL } from '#llm/index'
64
+ import { getCompletionSource } from '#core/completion.recorder'
65
+ import { providerKeyFromEnv, type LLMProvider, type LLMCallMeta, type ProviderCredential, type LLMWire } from '#llm/index'
66
+ import type { ModelRouter } from '#llm/routing'
64
67
  import { buildFallbackOutput, parseResponse } from '#faculties/executive.engine/parser'
65
68
  import { selectProcess, ideationTemperature, DELIBERATE_THRESHOLD } from '#faculties/executive.engine/effort.gate'
66
69
  import { proposeCandidates } from '#faculties/executive.engine/deliberate.reasoning'
@@ -145,14 +148,14 @@ export class ExecutiveEngine extends AsyncEngine implements CognitiveEngine {
145
148
 
146
149
  // ── Injected dependencies ──────────────────────────────────
147
150
  private _willId: string | null = null
148
- /** Per-Will, per-role model ids (config.model, resolved in mind.ts). */
149
- private _models: { executive: string | null; summarizer: string | null; deliberation: string | null; conversation: string | null } =
150
- { executive: null, summarizer: null, deliberation: null, conversation: null }
151
+ /**
152
+ * The Will's default model (config.model's `executive` role, resolved in
153
+ * mind.ts). Every other role reaches its model through the router — see
154
+ * `compileRoleRouter`.
155
+ */
156
+ private _modelId: string | null = null
151
157
  /** Per-Will LLM transport overrides (config.llm) — env fallbacks apply per field. */
152
- private _llm: { provider?: string; apiKey?: string; baseUrl?: string; maxOutputTokens?: number; timeoutMs?: number } | null = null
153
- /** One director per distinct model — same config, different model. Shared
154
- * tracker/recorder/willId, so ledger attribution and replay hold per role. */
155
- private _directorCache = new Map<string, LLMDirector>()
158
+ private _llm: { provider?: string; apiKey?: string; baseUrl?: string; maxOutputTokens?: number; timeoutMs?: number; credentials?: Partial<Record<string, ProviderCredential>>; router?: ModelRouter | null; wire?: LLMWire } | null = null
156
159
  private _workingMemory: WorkingMemory | null = null
157
160
  private _goalManager: GoalManager | null = null
158
161
  private _episodicConsolidator: EpisodicConsolidator | null = null
@@ -267,13 +270,18 @@ export class ExecutiveEngine extends AsyncEngine implements CognitiveEngine {
267
270
 
268
271
  set willId( willId: string ){ this._willId = willId }
269
272
 
270
- /** Per-Will role models (config.model, resolved). Set before the first tick. */
271
- set models( m: { executive: string | null; summarizer: string | null; deliberation: string | null; conversation: string | null } ){ this._models = m }
272
- get models(): { executive: string | null; summarizer: string | null; deliberation: string | null; conversation: string | null } { return this._models }
273
+ /**
274
+ * The Will's default model. Set before the first tick.
275
+ *
276
+ * This replaced a four-role map (W7): the other roles are routing rules now,
277
+ * compiled in mind.ts, so the engine holds one model and one router rather
278
+ * than a model per role plus a router.
279
+ */
280
+ set modelId( id: string | null ){ this._modelId = id }
281
+ get modelId(): string | null { return this._modelId }
282
+
273
283
  /** Per-Will LLM transport overrides (config.llm). Set before the first tick. */
274
- set llm( c: { provider?: string; apiKey?: string; baseUrl?: string; maxOutputTokens?: number; timeoutMs?: number } | null ){ this._llm = c }
275
- /** The executive-role model id (back-compat read). */
276
- get modelId(): string | null { return this._models.executive }
284
+ set llm( c: { provider?: string; apiKey?: string; baseUrl?: string; maxOutputTokens?: number; timeoutMs?: number; credentials?: Partial<Record<string, ProviderCredential>>; router?: ModelRouter | null; wire?: LLMWire } | null ){ this._llm = c }
277
285
 
278
286
  // ── Public surface ─────────────────────────────────────────
279
287
 
@@ -301,48 +309,98 @@ export class ExecutiveEngine extends AsyncEngine implements CognitiveEngine {
301
309
  * and subscribe() to receive facet decisions.
302
310
  */
303
311
  /** Get-or-create the director for a model id (shared config, per-Will). */
304
- private _directorFor( model: string ): LLMDirector {
305
- let d = this._directorCache.get( model )
306
- if( !d ){
307
- // Per-Will transport overrides first (BYO keys), env per field otherwise.
308
- d = new LLMDirector( {
309
- willId: this._willId!,
310
- model,
311
- maxOutputTokens: this._llm?.maxOutputTokens ?? parseInt( process.env.WILL_MAX_OUTPUT_TOKENS ?? '8096'),
312
- // Provider-agnostic key; falls back to ANTHROPIC_API_KEY for back-compat.
313
- apiKey: this._llm?.apiKey ?? process.env.WILL_LLM_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? '',
314
- provider: ( this._llm?.provider ?? process.env.WILL_LLM_PROVIDER ?? 'anthropic') as LLMProvider,
315
- // Optional base-URL override (e.g. Ollama / Azure / self-hosted). Unset →
316
- // the director uses the provider's official endpoint.
317
- baseUrl: this._llm?.baseUrl ?? process.env.WILL_LLM_BASE_URL ?? process.env.OPENAI_BASE_URL,
318
- timeoutMs: this._llm?.timeoutMs ?? ( process.env.WILL_LLM_TIMEOUT_MS ? parseInt( process.env.WILL_LLM_TIMEOUT_MS ) : undefined ),
319
- sessionLogger: this._sessionLogger,
320
- mock: this._testMode,
321
- // Inject the per-Will tracker (R4) so live calls record usage here, not
322
- // through a process global. null is fine — the director skips recording.
323
- tokenTracker: this._tokenTracker,
324
- } )
325
- this._directorCache.set( model, d )
326
- }
327
- return d
312
+ /**
313
+ * The provider, from config or environment — never guessed.
314
+ *
315
+ * This used to default to 'anthropic', which is how a Will configured for one
316
+ * vendor could quietly talk to another. An unset provider is a configuration
317
+ * error, and saying so at construction is far cheaper than a 401 mid-tick.
318
+ */
319
+ private _requireProvider(): LLMProvider {
320
+ const provider = this._llm?.provider ?? process.env.WILL_LLM_PROVIDER
321
+ ?? ( this._noLiveCalls() ? MOCK_PROVIDER : undefined )
322
+ if( !provider )
323
+ throw new Error(
324
+ 'No LLM provider configured. Set one on the Will (llm.provider) or in ' +
325
+ 'the environment (WILL_LLM_PROVIDER) the engine carries no default.'
326
+ )
327
+ return provider as LLMProvider
328
+ }
329
+
330
+ /**
331
+ * True when this Will cannot make a live call, so provider/model are not
332
+ * required: mock mode, or a replay re-feeding recorded completions.
333
+ */
334
+ private _noLiveCalls(): boolean {
335
+ return this._testMode || ( !!this._willId && !!getCompletionSource( this._willId ) )
328
336
  }
329
337
 
338
+ /**
339
+ * Build this Will's one and only director.
340
+ *
341
+ * There used to be a cache of them, keyed by model, because the per-role
342
+ * model map had no other way to make a role use a different model. Routing
343
+ * gave it one — the role map now compiles to rules (see `compileRoleRouter`)
344
+ * and a single director resolves every call's endpoint per call. That is also
345
+ * strictly more faithful: a facet follows the work it is doing rather than
346
+ * whatever role it happened to be spawned under.
347
+ */
348
+ private _buildDirector( model: string ): LLMDirector {
349
+ // Resolved first: the key fallback below is keyed by it.
350
+ const provider = this._requireProvider()
351
+
352
+ // Per-Will transport overrides first (BYO keys), env per field otherwise.
353
+ return new LLMDirector( {
354
+ willId: this._willId!,
355
+ model,
356
+ maxOutputTokens: this._llm?.maxOutputTokens ?? parseInt( process.env.WILL_MAX_OUTPUT_TOKENS ?? '8096'),
357
+ // Config, then the provider-agnostic env, then THIS provider's own env.
358
+ // The last step is not the fallback W9 removed: that one ended at
359
+ // ANTHROPIC_API_KEY for every provider, so a Will pointed elsewhere sent
360
+ // Anthropic's key to a stranger. This one can only ever read the key
361
+ // belonging to the provider actually configured.
362
+ apiKey: this._llm?.apiKey ?? process.env.WILL_LLM_API_KEY ?? providerKeyFromEnv( provider ) ?? '',
363
+ provider,
364
+ // Optional base-URL override (e.g. Ollama / Azure / self-hosted). Unset →
365
+ // the director uses the provider's official endpoint.
366
+ baseUrl: this._llm?.baseUrl ?? process.env.WILL_LLM_BASE_URL ?? process.env.OPENAI_BASE_URL,
367
+ timeoutMs: this._llm?.timeoutMs ?? ( process.env.WILL_LLM_TIMEOUT_MS ? parseInt( process.env.WILL_LLM_TIMEOUT_MS ) : undefined ),
368
+ sessionLogger: this._sessionLogger,
369
+ mock: this._testMode,
370
+ // Inject the per-Will tracker (R4) so live calls record usage here, not
371
+ // through a process global. null is fine — the director skips recording.
372
+ tokenTracker: this._tokenTracker,
373
+ // Credentials for routed calls, narrowed by the stem from the host's
374
+ // per-provider map. Prices from that same map ride to the TokenTracker
375
+ // instead, so nothing carries pricing into the call path.
376
+ ...( this._llm?.credentials ? { credentials: this._llm.credentials } : {} ),
377
+ // Per-call model selection — the host's router chained with the rules
378
+ // compiled from the per-role model map.
379
+ ...( this._llm?.router ? { router: this._llm.router } : {} ),
380
+ // Dialect for the default provider — required for anything outside the
381
+ // known set, so the engine never guesses how to talk to an endpoint.
382
+ ...( this._llm?.wire ? { wire: this._llm.wire } : {} ),
383
+ } )
384
+ }
385
+
386
+ /**
387
+ * Spawn a facet.
388
+ *
389
+ * `role` declares the facet's intent at the call site. It no longer selects a
390
+ * model: that used to happen here, pinning a facet for life to whatever role
391
+ * it was spawned under, and it now happens per call from the focus function
392
+ * the caller sets immediately afterwards (W7). The two always agreed — every
393
+ * spawn site sets a focus whose `function` matches its role — so the routed
394
+ * answer is the same one, decided later and from the work itself.
395
+ */
330
396
  spawnFacet( role?: 'deliberation' | 'conversation' | 'outreach' | 'supervision'): { attention: 'available' | 'full', handle?: ExecutiveFacetHandle } {
397
+ void role
331
398
  // Delegate to FacetSupervisor (R5-g-3), passing the current engine
332
399
  // attachments. The supervisor owns the registry + attention budget and
333
400
  // performs the throw-checks on bus / director / state ref.
334
- // A role with its own configured model gets that role's director; every
335
- // other facet shares the executive's (one self, role-appropriate depth).
336
- // Outreach speaks with the conversation voice; supervision thinks with the
337
- // executive's depth.
338
- const roleModel =
339
- role === 'deliberation' ? this._models.deliberation :
340
- role === 'conversation' || role === 'outreach' ? this._models.conversation :
341
- null
342
- const director = roleModel && this._llmDirector ? this._directorFor( roleModel ) : this._llmDirector
343
401
  return this._facetSupervisor.spawn( {
344
402
  bus: this._bus,
345
- llmDirector: director,
403
+ llmDirector: this._llmDirector,
346
404
  stateRef: this._lastStateRef,
347
405
  willId: this._willId,
348
406
  inbox: this._inbox,
@@ -433,15 +491,32 @@ export class ExecutiveEngine extends AsyncEngine implements CognitiveEngine {
433
491
  this._gatingState.executiveInterval = rtConfig.executiveInterval
434
492
  this._gatingState.cooldownTicks = rtConfig.cooldownTicks
435
493
 
436
- // Initialize LLM directors if not yet done (requires willId). One director
437
- // per distinct role model; roles that share a model share the instance.
494
+ // Initialize the LLM director if not yet done (requires willId). One per
495
+ // Will every role reaches its model through the router.
438
496
  if( !this._llmDirector && this._willId ){
439
- const execModel = this._models.executive ?? process.env.WILL_LLM_MODEL
440
- ?? defaultModelFor( ( this._llm?.provider ?? process.env.WILL_LLM_PROVIDER ?? 'anthropic') as LLMProvider )
441
- this._llmDirector = this._directorFor( execModel )
442
- // The summarizer runs its role's model (falls back to executive) with the
443
- // same provider, session logging and token tracking.
444
- this._summarizer?.attachLLMDirector( this._directorFor( this._models.summarizer ?? execModel ) )
497
+ // No default model. The engine used to fall back to a Claude id for every
498
+ // provider but GLM, which meant a misconfigured Will asked the wrong
499
+ // vendor for the wrong model and failed at the first tick with a 404
500
+ // instead of at construction with a sentence.
501
+ //
502
+ // Two cases legitimately have no credentials and are exempt: a test-mode
503
+ // Will (never reaches a network — demanding a key would break the whole
504
+ // point of the no-key quickstart) and a replay (completions are re-fed
505
+ // from the tape, which carries the model that actually served them). The
506
+ // sentinel is what a mock run records, so the tape still says plainly
507
+ // that nothing real answered.
508
+ const defaultModel = this._modelId ?? process.env.WILL_LLM_MODEL
509
+ ?? ( this._noLiveCalls() ? MOCK_MODEL : undefined )
510
+ if( !defaultModel )
511
+ throw new Error(
512
+ 'No LLM model configured. Set one on the Will (llm.model) or in the ' +
513
+ 'environment (WILL_LLM_MODEL) — the engine carries no default.'
514
+ )
515
+ this._llmDirector = this._buildDirector( defaultModel )
516
+ // The summarizer shares that director. Its calls tag themselves
517
+ // `category: 'summarizer'`, which is what a configured summarizer role
518
+ // compiles to — so it still gets its own model, chosen per call.
519
+ this._summarizer?.attachLLMDirector( this._llmDirector )
445
520
  }
446
521
 
447
522
  // Evaluate gating
@@ -593,7 +668,7 @@ export class ExecutiveEngine extends AsyncEngine implements CognitiveEngine {
593
668
  ideationUserMessage,
594
669
  tick: state.tick,
595
670
  proposeTemperature,
596
- meta: { category: 'executive', attribute: 'master', function: 'ideation' },
671
+ meta: { category: 'executive', attribute: 'master', function: 'ideation', demand: processSelection.effortScore },
597
672
  } )
598
673
  logger.info(
599
674
  `[executive] ◆ deliberate propose tick=${state.tick} ` +
@@ -646,7 +721,10 @@ export class ExecutiveEngine extends AsyncEngine implements CognitiveEngine {
646
721
 
647
722
  try {
648
723
  // Use streaming call when clients are connected (F3); fall back to regular call.
649
- const masterMeta = { category: 'executive', attribute: 'master', function: 'decision' }
724
+ // MODEL_ROUTING W0 the effort gate already weighed this tick's demand
725
+ // (uncertainty, prior confidence, novelty, a pending reply, stress load);
726
+ // forward it rather than inventing a second measure of the same thing.
727
+ const masterMeta: LLMCallMeta = { category: 'executive', attribute: 'master', function: 'decision', demand: processSelection.effortScore }
650
728
  const result = this._chunkBroadcaster
651
729
  ? await this._llmDirector.callStream( systemPrompt, userMessage, state.tick, this._chunkBroadcaster, undefined, masterMeta )
652
730
  : await this._llmDirector.call( systemPrompt, userMessage, state.tick, undefined, masterMeta )
@@ -424,7 +424,7 @@ export class ExecutiveFacet {
424
424
  ideationUserMessage,
425
425
  tick: currentState.tick,
426
426
  proposeTemperature,
427
- meta: { category: 'executive', attribute: 'facet', function: this._currentFocus?.function ?? 'ideation', scope: this.facetId },
427
+ meta: { category: 'executive', attribute: 'facet', function: this._currentFocus?.function ?? 'ideation', scope: this.facetId, demand: processSelection.effortScore },
428
428
  } )
429
429
  logger.info(
430
430
  `[executive.facet] ${this.facetId} ◆ deliberate propose tick=${currentState.tick} ` +
@@ -465,11 +465,19 @@ export class ExecutiveFacet {
465
465
  // Use streaming call when a per-facet chunk handler is registered —
466
466
  // this enables entity-scoped token delivery (e.g. AuditionEngine SSE).
467
467
  // Falls back to regular call when no handler is present.
468
+ // MODEL_ROUTING W0 — same effort gate as master, so a facet's demand is
469
+ // measured on the identical scale (a live message awaiting reply is this
470
+ // focus's stakes-bearing moment, and already weighs into the score).
468
471
  const facetMeta: LLMCallMeta = {
469
472
  category: 'executive',
470
473
  attribute: 'facet',
471
- function: this._currentFocus?.function ?? 'facet',
474
+ // A focus that declares no function is making its decision call, the
475
+ // facet's analogue of master's 'decision'. This previously fell back to
476
+ // 'facet' — an *attribute* value, which quietly created a bogus bucket
477
+ // in the by-function cost breakdown. The typed axes caught it.
478
+ function: this._currentFocus?.function ?? 'decision',
472
479
  scope: this.facetId,
480
+ demand: processSelection.effortScore,
473
481
  }
474
482
  const result = this._chunkHandler
475
483
  ? await this._llmDirector.callStream( systemPrompt, userMessage, currentState.tick, this._chunkHandler, undefined, facetMeta )
@@ -45,6 +45,7 @@
45
45
  */
46
46
 
47
47
  import type { ReadonlySimulationState } from '#core/types'
48
+ import type { LLMCallFunction } from '#cognition/utilities/token.tracker'
48
49
  import type { ExecutiveSummarizer } from '#llm/summarizer'
49
50
  import type { ExecutiveContext, PendingMessage, IdeationCandidate } from '#faculties/executive.engine/types'
50
51
  import { buildExecutiveContext, type ContextDependencies } from '#faculties/executive.engine/context'
@@ -158,7 +159,7 @@ export interface FocusSection {
158
159
  * into the facet's LLM calls as `LLMCallMeta.function` so the TokenTracker can
159
160
  * break spend down per facet type. Defaults to 'facet' when unset.
160
161
  */
161
- function?: string
162
+ function?: LLMCallFunction
162
163
  /**
163
164
  * Optional: Custom output format to append instead of the standard executive format.
164
165
  * Pass via PromptBuildOptions.outputFormat when building the user message.
@@ -9,6 +9,8 @@ import {
9
9
  type TokenUsage,
10
10
  type TokenLedgerRecord,
11
11
  type RecordUsageInput,
12
+ type PriceTable,
13
+ type ModelPrice,
12
14
  } from '#cognition/utilities/token.tracker'
13
15
 
14
16
  import { EnergyRegulator, type EnergyRegulatorConfig } from '#faculties/energy.regulator'
@@ -84,6 +86,8 @@ export {
84
86
  type TokenUsage,
85
87
  type TokenLedgerRecord,
86
88
  type RecordUsageInput,
89
+ type PriceTable,
90
+ type ModelPrice,
87
91
 
88
92
  // ── Regulatory Engines ─────────────────────────────────────
89
93
 
@@ -12,6 +12,10 @@
12
12
  */
13
13
 
14
14
  import type { TokenTracker } from '#cognition/utilities/token.tracker'
15
+ import type { LLMCallFunction } from '#cognition/utilities/token.tracker'
16
+
17
+ /** Embedding is only ever a read or a write. */
18
+ export type EmbedFunction = Extract<LLMCallFunction, 'recall' | 'index'>
15
19
 
16
20
  export interface EmbeddingProvider {
17
21
  readonly modelName: string
@@ -55,7 +59,7 @@ export class OpenAICompatibleEmbedder implements EmbeddingProvider {
55
59
  /**
56
60
  * Per-Will token tracker. When provided, each embedding call records its
57
61
  * input-token usage under the 'embedding' category so memory-vector spend is
58
- * visible alongside LLM spend instead of being a silent COGS leak.
62
+ * visible alongside LLM spend instead of being a silent cost leak.
59
63
  */
60
64
  tokenTracker?: TokenTracker | null
61
65
  } ){
@@ -68,7 +72,7 @@ export class OpenAICompatibleEmbedder implements EmbeddingProvider {
68
72
  this._tokenTracker = config.tokenTracker ?? null
69
73
  }
70
74
 
71
- async embed( content: unknown, fn: string = 'recall'): Promise<number[]> {
75
+ async embed( content: unknown, fn: EmbedFunction = 'recall'): Promise<number[]> {
72
76
  let response: Response
73
77
  try {
74
78
  response = await fetch(`${this._apiUrl}/embeddings`, {
@@ -128,7 +132,7 @@ export class OpenAICompatibleEmbedder implements EmbeddingProvider {
128
132
  return embedding
129
133
  }
130
134
 
131
- async embedBatch( contents: unknown[], fn: string = 'index'): Promise<number[][]> {
135
+ async embedBatch( contents: unknown[], fn: EmbedFunction = 'index'): Promise<number[][]> {
132
136
  // Bounded fan-out: cap concurrent requests at _maxConcurrency instead of
133
137
  // firing all of them at once (FN16), while preserving input order.
134
138
  const results: number[][] = new Array( contents.length )
@@ -170,7 +174,7 @@ export class MockEmbedder implements EmbeddingProvider {
170
174
  this._seed = seed
171
175
  }
172
176
 
173
- async embed( content: unknown, _fn: string = 'recall'): Promise<number[]> {
177
+ async embed( content: unknown, _fn: EmbedFunction = 'recall'): Promise<number[]> {
174
178
  const str = typeof content === 'string' ? content : JSON.stringify( content )
175
179
  const hash = this._hashString( str )
176
180
  const embedding: number[] = []
@@ -184,7 +188,7 @@ export class MockEmbedder implements EmbeddingProvider {
184
188
  return embedding
185
189
  }
186
190
 
187
- async embedBatch( contents: unknown[], fn: string = 'index'): Promise<number[][]> {
191
+ async embedBatch( contents: unknown[], fn: EmbedFunction = 'index'): Promise<number[][]> {
188
192
  return Promise.all( contents.map( c => this.embed( c, fn ) ) )
189
193
  }
190
194