@mindot/will 0.1.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 (178) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +777 -0
  3. package/dist/index.d.ts +8144 -0
  4. package/dist/index.js +26231 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +73 -0
  7. package/src/cognition/agency/access.grants.ts +59 -0
  8. package/src/cognition/agency/competence.codec.ts +89 -0
  9. package/src/cognition/agency/engines/action.selector.ts +462 -0
  10. package/src/cognition/agency/engines/affordance.synthesizer.ts +384 -0
  11. package/src/cognition/agency/engines/deliberation.engine.ts +241 -0
  12. package/src/cognition/agency/engines/instruction.intake.ts +54 -0
  13. package/src/cognition/agency/engines/motor.schema.executor.ts +532 -0
  14. package/src/cognition/agency/engines/reafference.engine.ts +241 -0
  15. package/src/cognition/agency/execution.primitives.ts +118 -0
  16. package/src/cognition/agency/index.ts +58 -0
  17. package/src/cognition/agency/proactive.communicator.ts +274 -0
  18. package/src/cognition/agency/reconcile.learning.ts +82 -0
  19. package/src/cognition/agency/schemas/external.ts +59 -0
  20. package/src/cognition/agency/schemas/innate.ts +111 -0
  21. package/src/cognition/agency/schemas/repertoire.ts +246 -0
  22. package/src/cognition/agency/selection.scoring.ts +191 -0
  23. package/src/cognition/agency/types.ts +165 -0
  24. package/src/cognition/bus.ts +391 -0
  25. package/src/cognition/completion.inbox.ts +86 -0
  26. package/src/cognition/config.mirror.entities.ts +475 -0
  27. package/src/cognition/conversation.memory.ts +81 -0
  28. package/src/cognition/event.log.ts +116 -0
  29. package/src/cognition/event.schemas.ts +568 -0
  30. package/src/cognition/faculties/aesthetic.evaluator.ts +313 -0
  31. package/src/cognition/faculties/affective.blender.ts +562 -0
  32. package/src/cognition/faculties/attachment.evaluator.ts +432 -0
  33. package/src/cognition/faculties/attention.allocator.ts +422 -0
  34. package/src/cognition/faculties/autobiographical.narrator.ts +300 -0
  35. package/src/cognition/faculties/bias.detector.ts +363 -0
  36. package/src/cognition/faculties/circadian.oscillator.ts +294 -0
  37. package/src/cognition/faculties/confidence.calibrator.ts +324 -0
  38. package/src/cognition/faculties/dream.simulator.ts +251 -0
  39. package/src/cognition/faculties/empathy.simulator.ts +253 -0
  40. package/src/cognition/faculties/energy.regulator.ts +320 -0
  41. package/src/cognition/faculties/episodic.consolidator.ts +728 -0
  42. package/src/cognition/faculties/executive.engine/commands.ts +459 -0
  43. package/src/cognition/faculties/executive.engine/config.ts +46 -0
  44. package/src/cognition/faculties/executive.engine/context.ts +605 -0
  45. package/src/cognition/faculties/executive.engine/deferred.effects.ts +104 -0
  46. package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +60 -0
  47. package/src/cognition/faculties/executive.engine/effort.gate.ts +114 -0
  48. package/src/cognition/faculties/executive.engine/engine.ts +1028 -0
  49. package/src/cognition/faculties/executive.engine/escalation.buffer.ts +90 -0
  50. package/src/cognition/faculties/executive.engine/facet.supervisor.ts +271 -0
  51. package/src/cognition/faculties/executive.engine/facet.ts +628 -0
  52. package/src/cognition/faculties/executive.engine/gating.ts +222 -0
  53. package/src/cognition/faculties/executive.engine/index.ts +6 -0
  54. package/src/cognition/faculties/executive.engine/messages.ts +102 -0
  55. package/src/cognition/faculties/executive.engine/parser.ts +380 -0
  56. package/src/cognition/faculties/executive.engine/prompt.factory.ts +1053 -0
  57. package/src/cognition/faculties/executive.engine/types.ts +323 -0
  58. package/src/cognition/faculties/exteroception.ts +338 -0
  59. package/src/cognition/faculties/forgetting.curve.ts +202 -0
  60. package/src/cognition/faculties/frustration.evaluator.ts +280 -0
  61. package/src/cognition/faculties/goal.manager.ts +1008 -0
  62. package/src/cognition/faculties/inhibition.controller.ts +351 -0
  63. package/src/cognition/faculties/interoception.ts +417 -0
  64. package/src/cognition/faculties/introspection.engine.ts +246 -0
  65. package/src/cognition/faculties/known.entity.tracker.ts +439 -0
  66. package/src/cognition/faculties/loss.evaluator.ts +224 -0
  67. package/src/cognition/faculties/moral.evaluator.ts +437 -0
  68. package/src/cognition/faculties/novelty.detector.ts +254 -0
  69. package/src/cognition/faculties/persona.consolidator.ts +845 -0
  70. package/src/cognition/faculties/planning.engine/engine.ts +859 -0
  71. package/src/cognition/faculties/planning.engine/plan.frontier.ts +83 -0
  72. package/src/cognition/faculties/planning.engine/plan.store.ts +174 -0
  73. package/src/cognition/faculties/planning.engine/plan.supervision.ts +494 -0
  74. package/src/cognition/faculties/planning.engine/types.ts +143 -0
  75. package/src/cognition/faculties/reputation.tracker.ts +294 -0
  76. package/src/cognition/faculties/reward.evaluator.ts +304 -0
  77. package/src/cognition/faculties/self.model.updater.ts +677 -0
  78. package/src/cognition/faculties/semantic.engine/clustering.ts +673 -0
  79. package/src/cognition/faculties/semantic.engine/index.ts +6 -0
  80. package/src/cognition/faculties/semantic.engine/integrator.ts +750 -0
  81. package/src/cognition/faculties/semantic.engine/types.ts +53 -0
  82. package/src/cognition/faculties/sleep.pressure.regulator.ts +262 -0
  83. package/src/cognition/faculties/social.perception.ts +333 -0
  84. package/src/cognition/faculties/spaced.repetition.ts +605 -0
  85. package/src/cognition/faculties/stress.regulator.ts +375 -0
  86. package/src/cognition/faculties/task.switcher.ts +239 -0
  87. package/src/cognition/faculties/theory.of.mind.ts +333 -0
  88. package/src/cognition/faculties/threat.evaluator.ts +312 -0
  89. package/src/cognition/faculties/working.memory.ts +406 -0
  90. package/src/cognition/generative.model.ts +344 -0
  91. package/src/cognition/heartbeat.ts +70 -0
  92. package/src/cognition/index.ts +291 -0
  93. package/src/cognition/instruction.handler.ts +146 -0
  94. package/src/cognition/memory/index.ts +35 -0
  95. package/src/cognition/memory/vector.adapter.ts +316 -0
  96. package/src/cognition/memory/vector.content.ts +47 -0
  97. package/src/cognition/memory/vector.embedder.ts +212 -0
  98. package/src/cognition/memory/vector.index.ts +459 -0
  99. package/src/cognition/memory/vector.types.ts +65 -0
  100. package/src/cognition/orchestrator.ts +142 -0
  101. package/src/cognition/persona.prior.ts +265 -0
  102. package/src/cognition/schema.registry.ts +145 -0
  103. package/src/cognition/senses/audition.engine/engine.ts +976 -0
  104. package/src/cognition/senses/audition.engine/salience.ts +59 -0
  105. package/src/cognition/senses/base.sense.engine.ts +117 -0
  106. package/src/cognition/senses/gustation.engine.ts +24 -0
  107. package/src/cognition/senses/index.ts +172 -0
  108. package/src/cognition/senses/olfaction.engine.ts +24 -0
  109. package/src/cognition/senses/somatosensation.engine.ts +23 -0
  110. package/src/cognition/senses/vision.engine.ts +23 -0
  111. package/src/cognition/types.ts +63 -0
  112. package/src/cognition/utilities/token.tracker.ts +492 -0
  113. package/src/core/abstracts.ts +60 -0
  114. package/src/core/async.engine.ts +539 -0
  115. package/src/core/clock.ts +196 -0
  116. package/src/core/completion.recorder.ts +122 -0
  117. package/src/core/conflict.detector.ts +140 -0
  118. package/src/core/distributed.ts +626 -0
  119. package/src/core/event.bus.ts +292 -0
  120. package/src/core/inbound.recorder.ts +100 -0
  121. package/src/core/index.ts +167 -0
  122. package/src/core/logger.ts +85 -0
  123. package/src/core/metrics.ts +107 -0
  124. package/src/core/orchestrator.ts +625 -0
  125. package/src/core/replay.ts +589 -0
  126. package/src/core/scenario.ts +120 -0
  127. package/src/core/serialization.ts +448 -0
  128. package/src/core/simulation.ts +197 -0
  129. package/src/core/snapshot.manager.ts +293 -0
  130. package/src/core/state.manager.ts +283 -0
  131. package/src/core/types.ts +274 -0
  132. package/src/core/utils.ts +82 -0
  133. package/src/core/wall.clock.ts +23 -0
  134. package/src/extensions/livestream.ext.ts +570 -0
  135. package/src/extensions/time.ext.ts +339 -0
  136. package/src/index.ts +91 -0
  137. package/src/llm/gate.ts +140 -0
  138. package/src/llm/index.ts +716 -0
  139. package/src/llm/summarizer.ts +170 -0
  140. package/src/llm/wire.contracts.ts +67 -0
  141. package/src/pma/eval.ts +651 -0
  142. package/src/pma/index.ts +1082 -0
  143. package/src/profiles/built-in.ts +7 -0
  144. package/src/profiles/companion.ts +28 -0
  145. package/src/profiles/company-brain.ts +54 -0
  146. package/src/profiles/customer-service.ts +30 -0
  147. package/src/profiles/game-npc.ts +20 -0
  148. package/src/profiles/index.ts +42 -0
  149. package/src/profiles/smart-home.ts +34 -0
  150. package/src/runners/coherence.runner.ts +49 -0
  151. package/src/runners/outreach.runner.ts +158 -0
  152. package/src/runners/social.runner.ts +182 -0
  153. package/src/runners/thin-shim.runner.ts +195 -0
  154. package/src/sdk/will.ts +334 -0
  155. package/src/stem/assembly.audit.ts +88 -0
  156. package/src/stem/distribution.ts +203 -0
  157. package/src/stem/guards/identity.coherence.ts +152 -0
  158. package/src/stem/guards/identity.guard.ts +227 -0
  159. package/src/stem/index.ts +1145 -0
  160. package/src/stem/mind.ts +1090 -0
  161. package/src/stem/tracts/ack.reconciler.ts +55 -0
  162. package/src/stem/tracts/biography.writer.ts +190 -0
  163. package/src/stem/tracts/effector.controller.ts +143 -0
  164. package/src/stem/tracts/health.reporter.ts +113 -0
  165. package/src/stem/tracts/inbound.queue.ts +54 -0
  166. package/src/stem/tracts/outbox.controller.ts +167 -0
  167. package/src/stem/tracts/outbox.writer.ts +133 -0
  168. package/src/stem/tracts/pma.controller.ts +109 -0
  169. package/src/stem/tracts/replay.controller.ts +177 -0
  170. package/src/stem/tracts/sensory.controller.ts +168 -0
  171. package/src/stem/tracts/session.logger.ts +157 -0
  172. package/src/stem/tracts/transport/index.ts +38 -0
  173. package/src/stem/tracts/transport/loopback.transport.ts +86 -0
  174. package/src/stem/tracts/transport/socketio.transport.ts +200 -0
  175. package/src/stem/tracts/transport/stream.transport.ts +152 -0
  176. package/src/stem/tracts/transport/types.ts +193 -0
  177. package/src/stem/tracts/transport.controller.ts +380 -0
  178. package/src/types.ts +116 -0
@@ -0,0 +1,716 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // src/llm/index.ts
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import { logger } from '#core/logger'
6
+ import type { Tick } from '#core/types'
7
+ import type { SessionLogger } from '#stem/tracts/session.logger'
8
+ import { writeFileSync, mkdirSync } from 'node:fs'
9
+ import type { TokenTracker } from '#cognition/utilities/token.tracker'
10
+ import { getCompletionRecorder, getCompletionSource } from '#core/completion.recorder'
11
+ import type { LLMCompletionRecord } from '#core/completion.recorder'
12
+ import { withGate } from '#llm/gate'
13
+ import { matchConversationFocus, wrapReplyText } from '#llm/wire.contracts'
14
+
15
+ export type LLMProvider = 'anthropic' | 'deepseek' | 'openai' | 'google'
16
+ export interface LLMDirectorConfig {
17
+ willId: string
18
+ model: string
19
+ maxOutputTokens: number
20
+ apiKey: string
21
+ provider: LLMProvider
22
+ sessionLogger: SessionLogger | null
23
+ /** When true, all LLM calls return canned deterministic responses — no API cost. */
24
+ mock?: boolean
25
+ /**
26
+ * Override the provider's API base URL (including the version segment, e.g.
27
+ * `http://localhost:11434/v1` for Ollama). Defaults to the provider's
28
+ * official endpoint. This is what lets `deepseek` actually reach DeepSeek
29
+ * and lets `openai` point at any OpenAI-compatible server.
30
+ */
31
+ baseUrl?: string
32
+ /**
33
+ * Timeout in ms. On the Anthropic streaming path this is a *first-byte*
34
+ * (time-to-first-token) deadline — a long-but-healthy completion is never
35
+ * aborted mid-generation. Other providers treat it as a whole-request
36
+ * deadline. Default 90s.
37
+ */
38
+ timeoutMs?: number
39
+ /**
40
+ * Per-Will token tracker (R4). When provided, completed live calls record
41
+ * their usage/cost here. Omitted/null → recording is skipped (e.g. mock or
42
+ * replay runs). This replaces the former process-global getTokenTracker().
43
+ */
44
+ tokenTracker?: TokenTracker | null
45
+ }
46
+
47
+ // ── LLM call result ──────────────────────────────────────────
48
+
49
+ export interface LLMCallResult {
50
+ text: string
51
+ inputTok: number
52
+ outputTok: number
53
+ /** Anthropic prompt-cache: tokens served from cache (~0.1× cost). Telemetry. */
54
+ cacheReadTok?: number
55
+ /** Anthropic prompt-cache: tokens written to cache this call (~1.25× cost). Telemetry. */
56
+ cacheWriteTok?: number
57
+ }
58
+
59
+ /**
60
+ * Cost-attribution metadata for a single LLM call. The same director instance is
61
+ * shared by the master executive, every facet (conversation/planning/outreach),
62
+ * the summarizer, and the ideation/propose pass — so the *call site* tags itself
63
+ * here, letting the TokenTracker break spend down per category for transparency.
64
+ */
65
+ export interface LLMCallMeta {
66
+ /** Top-level cost bucket: 'executive' | 'summarizer' | 'embedding' | 'identity-guard' | … */
67
+ category: string
68
+ /** The actor/subsystem doing the work: 'master' | 'facet' | 'memory' | 'guard' | … */
69
+ attribute: string
70
+ /** The specific cognitive function: 'decision' | 'ideation' | 'conversation' | 'planning' | 'deliberation' | 'outreach' | 'consolidation' | 'recall' | 'index' | 'identity-coherence' | … */
71
+ function: string
72
+ /** Optional specific id or namespace: facet id, entity id, model name. */
73
+ scope?: string
74
+ /** Free-form human-readable label. Auto-composed from the axes when omitted. */
75
+ label?: string
76
+ }
77
+
78
+ /** Default attribution when a caller does not tag itself (back-compat). */
79
+ const DEFAULT_CALL_META: LLMCallMeta = { category: 'executive', attribute: 'master', function: 'decision' }
80
+
81
+ export class LLMDirector {
82
+ private _willId: string
83
+ private _model: string
84
+ private _maxOutputTokens: number
85
+ private _apiKey: string
86
+ private _provider: LLMProvider
87
+ private _sessionLogger: SessionLogger | null
88
+ private _mock: boolean
89
+ private _baseUrl: string | null
90
+ private _timeoutMs: number
91
+ private _tokenTracker: TokenTracker | null
92
+
93
+ constructor( config: LLMDirectorConfig ) {
94
+ this._willId = config.willId
95
+ this._model = config.model
96
+ this._maxOutputTokens = config.maxOutputTokens
97
+ this._apiKey = config.apiKey
98
+ this._provider = config.provider
99
+ this._sessionLogger = config.sessionLogger
100
+ this._mock = config.mock ?? false
101
+ this._baseUrl = config.baseUrl ?? null
102
+ this._timeoutMs = config.timeoutMs ?? 90_000
103
+ this._tokenTracker = config.tokenTracker ?? null
104
+ }
105
+
106
+ // ── Mock response (test mode) ────────────────────────────
107
+
108
+ /**
109
+ * Returns a structurally valid executive output with zero API cost.
110
+ * Used when `mock: true` — e.g. for `bw_test_` key holders and the Playground.
111
+ *
112
+ * The response rotates through a small set of cognitively distinct actions so
113
+ * the Will's state panel shows believable variety across ticks.
114
+ */
115
+ /**
116
+ * Returns a deterministic mock LLM response parseable by the executive engine.
117
+ *
118
+ * Format for a conversation-facet turn (AuditionEngine): the facet focus renders
119
+ * Speaker: <name> (id: <entityId>)
120
+ * Current message: "<content>"
121
+ * and CONVERSATION_OUTPUT_FORMAT expects a JSON reasoning object followed by a
122
+ * [REPLY_TEXT] block — the block is the only part that reaches the speaker
123
+ * (streamed live, then landed in the outbox by the facet decision).
124
+ *
125
+ * Format for background ticks (no conversation focus):
126
+ * {"actions":[{"type":"...","reasoning":"...","expectedOutcome":"..."}],...}
127
+ * Strategy 1 (JSON.parse) handles this directly.
128
+ */
129
+ private _mockResponse( tick: number, userMessage: string = '' ): LLMCallResult {
130
+ // ── Conversation facet turn ────────────────────────────────
131
+ // Detect the AuditionEngine facet focus via the SHARED contract (see
132
+ // llm/wire.contracts.ts) — the render/match pair whose earlier drift
133
+ // silently broke every test-mode conversation.
134
+ const turn = matchConversationFocus( userMessage )
135
+
136
+ if( turn ){
137
+ const content = turn.content
138
+
139
+ const REPLY_CYCLES = [
140
+ `Hi! You said: "${content.length > 50 ? content.slice( 0, 50 ) + '…' : content}" — I heard you, and I'm listening.`,
141
+ `That's something worth thinking about. Tell me more about what's on your mind.`,
142
+ `I'm here, and I find myself genuinely curious about what you mean by that.`,
143
+ `I want to engage with what you're saying honestly — say more.`,
144
+ ]
145
+
146
+ const reply = REPLY_CYCLES[ tick % REPLY_CYCLES.length ]!
147
+
148
+ const text = [
149
+ '```json',
150
+ JSON.stringify({
151
+ actions: [],
152
+ reasoning: 'Someone is speaking with me. I want to respond genuinely from who I am.',
153
+ confidence: 0.85,
154
+ }),
155
+ '```',
156
+ '',
157
+ wrapReplyText( reply ),
158
+ ].join('\n')
159
+
160
+ return { text, inputTok: 0, outputTok: 0 }
161
+ }
162
+
163
+ // ── Background tick — no pending conversation ─────────────
164
+ // Output valid JSON so Strategy 1 (JSON.parse) succeeds directly.
165
+ const BG_CYCLES = [
166
+ { type: 'observe', reasoning: 'Taking stock of my environment — everything seems calm.', outcome: 'Better situational awareness' },
167
+ { type: 'reflect', reasoning: 'Exploring my sense of purpose and what matters to me.', outcome: 'Deeper self-understanding' },
168
+ { type: 'learn', reasoning: 'Integrating what I have perceived and experienced recently.', outcome: 'Richer context model' },
169
+ { type: 'express_emotion',reasoning: 'Acknowledging a feeling of openness and curiosity.', outcome: 'Emotional authenticity' },
170
+ ]
171
+
172
+ const bg = BG_CYCLES[ tick % BG_CYCLES.length ]!
173
+ const text = JSON.stringify({
174
+ actions: [ { type: bg.type, reasoning: bg.reasoning, expectedOutcome: bg.outcome } ],
175
+ reasoning: `Background cycle ${tick % BG_CYCLES.length + 1}. ${bg.reasoning}`,
176
+ confidence: 0.7,
177
+ })
178
+
179
+ return { text, inputTok: 0, outputTok: 0 }
180
+ }
181
+
182
+ // ── Chunk streaming ─────────────────────────────────────────
183
+
184
+ /**
185
+ * Stream tokens from the LLM. Calls `onChunk` for each text delta as it
186
+ * arrives, then returns the full result once the stream is done.
187
+ * Currently Anthropic only — other providers fall back to a single-chunk call.
188
+ */
189
+ async callStream(
190
+ systemPrompt: string,
191
+ userMessage: string,
192
+ tick: number,
193
+ onChunk: ( chunk: string ) => void,
194
+ /** Optional sampling temperature. Omitted ⇒ the provider's default. */
195
+ temperature?: number,
196
+ /** Cost-attribution tag for this call. Defaults to the master executive. */
197
+ meta: LLMCallMeta = DEFAULT_CALL_META,
198
+ ): Promise<LLMCallResult> {
199
+ const start = Date.now()
200
+
201
+ // Replay re-feed takes precedence over both live and mock paths (R2-c).
202
+ const replay = this._replayCompletion( systemPrompt, userMessage, tick )
203
+ if( replay ){
204
+ // Mirror the original run's streaming: the live (non-mock) path streamed
205
+ // raw text via onChunk, while the mock path intentionally did not.
206
+ if( !replay.mock ) onChunk( replay.text )
207
+ return { text: replay.text, inputTok: replay.inputTok, outputTok: replay.outputTok }
208
+ }
209
+
210
+ if( this._mock ){
211
+ const result = this._mockResponse( tick, userMessage )
212
+ // In mock mode we don't stream raw internal text — the response will be
213
+ // emitted from the outbox content by the SSE layer. onChunk is intentionally
214
+ // not called here so no internal [REPLY] / JSON format leaks to the client.
215
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - start, true )
216
+ return result
217
+ }
218
+
219
+ const result = this._provider === 'anthropic'
220
+ ? await this._callAnthropicStream( systemPrompt, userMessage, onChunk, temperature )
221
+ : await ( async () => {
222
+ // Other providers: fall back to regular call, emit whole response as one chunk
223
+ const r = await this._callProvider( systemPrompt, userMessage, temperature )
224
+ onChunk( r.text )
225
+ return r
226
+ } )()
227
+
228
+ // Token tracking lives here too: streamed calls (conversation facets, the
229
+ // master when broadcasting) previously bypassed the tracker entirely, so all
230
+ // streamed spend was invisible. Record it with the caller's attribution.
231
+ this._track( result, meta, tick, Date.now() - start, this._estPromptTokens( systemPrompt, userMessage ) )
232
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - start, false )
233
+ return result
234
+ }
235
+
236
+ /**
237
+ * Record a completed live call's token usage + cost into this Will's tracker,
238
+ * tagged with the caller's attribution (category/label). Optional — absent on
239
+ * mock/replay directors, so the call is simply skipped. Cache read/write tokens
240
+ * are forwarded so the tracker prices them at 0.1× / 1.25× input.
241
+ */
242
+ private _track( result: LLMCallResult, meta: LLMCallMeta, tick: Tick, latencyMs: number, estPromptTokens?: number ): void {
243
+ this._tokenTracker?.recordUsage({
244
+ model: this._model,
245
+ promptTokens: result.inputTok,
246
+ completionTokens: result.outputTok,
247
+ totalTokens: result.inputTok + result.outputTok,
248
+ cacheReadTokens: result.cacheReadTok,
249
+ cacheWriteTokens: result.cacheWriteTok,
250
+ category: meta.category,
251
+ attribute: meta.attribute,
252
+ function: meta.function,
253
+ scope: meta.scope,
254
+ label: meta.label,
255
+ estPromptTokens,
256
+ tick,
257
+ latencyMs,
258
+ })
259
+ }
260
+
261
+ /** Pre-cache prompt size estimate (chars/4) — mirrors the old token-report `ourEstTok`. */
262
+ private _estPromptTokens( systemPrompt: string, userMessage: string ): number {
263
+ return Math.round( ( systemPrompt.length + userMessage.length ) / 4 )
264
+ }
265
+
266
+ /**
267
+ * Capture an LLM completion into the active replay recorder for this Will,
268
+ * when one is registered (see core/completion.recorder). No-op otherwise.
269
+ * The LLM is the non-deterministic oracle; recording its input+output is the
270
+ * prerequisite for deterministic re-execution (REORIENT R2, deferred).
271
+ */
272
+ private _recordCompletion(
273
+ systemPrompt: string,
274
+ userMessage: string,
275
+ tick: number,
276
+ result: LLMCallResult,
277
+ latencyMs: number,
278
+ mock: boolean,
279
+ ): void {
280
+ try {
281
+ getCompletionRecorder( this._willId )?.recordCompletion({
282
+ tick,
283
+ willId: this._willId,
284
+ provider: this._provider,
285
+ model: this._model,
286
+ maxOutputTokens: this._maxOutputTokens,
287
+ systemPrompt,
288
+ userMessage,
289
+ text: result.text,
290
+ inputTok: result.inputTok,
291
+ outputTok: result.outputTok,
292
+ mock,
293
+ latencyMs,
294
+ timestamp: Date.now(),
295
+ })
296
+ }
297
+ catch { /* recorder is optional — never let recording break a completion */ }
298
+ }
299
+
300
+ /**
301
+ * Replay re-feed (REORIENT R2-c). When a completion source is registered for
302
+ * this Will, return the recorded completion for `tick` instead of calling the
303
+ * non-deterministic model. The source verifies the prompt and throws on a miss
304
+ * or divergence, so a replay can never silently re-call the LLM. Returns
305
+ * `undefined` when no source is registered (the normal live path).
306
+ */
307
+ private _replayCompletion(
308
+ systemPrompt: string,
309
+ userMessage: string,
310
+ tick: number,
311
+ ): LLMCompletionRecord | undefined {
312
+ return getCompletionSource( this._willId )?.nextCompletion( tick, systemPrompt, userMessage )
313
+ }
314
+
315
+ private async _callAnthropicStream(
316
+ systemPrompt: string,
317
+ userMessage: string,
318
+ onChunk: ( chunk: string ) => void,
319
+ temperature?: number,
320
+ ): Promise<LLMCallResult> {
321
+ // First-byte deadline only: a hard timeout on the whole stream would
322
+ // truncate a healthy but long generation, so the timer is cleared once
323
+ // the response headers arrive.
324
+ const controller = new AbortController()
325
+ const timer = setTimeout( () => controller.abort(), this._timeoutMs )
326
+
327
+ let res: Response
328
+ try {
329
+ res = await fetch(`${this._resolvedBase()}/messages`, {
330
+ method: 'POST',
331
+ headers: {
332
+ 'Content-Type': 'application/json',
333
+ 'anthropic-version': '2023-06-01',
334
+ 'x-api-key': this._apiKey,
335
+ },
336
+ body: JSON.stringify({
337
+ model: this._model,
338
+ max_tokens: this._maxOutputTokens,
339
+ ...( temperature !== undefined ? { temperature } : {} ),
340
+ stream: true,
341
+ system: this._systemField( systemPrompt ),
342
+ messages: [{ role: 'user', content: userMessage }],
343
+ }),
344
+ signal: controller.signal,
345
+ })
346
+ }
347
+ catch( err ){
348
+ clearTimeout( timer )
349
+ if( controller.signal.aborted )
350
+ throw new Error( `LLM stream to ${this._provider} timed out after ${this._timeoutMs}ms (no response)` )
351
+ throw err
352
+ }
353
+
354
+ clearTimeout( timer )
355
+
356
+ if( !res.ok )
357
+ throw new Error(`Anthropic stream ${res.status}: ${( await res.text() ).slice(0, 300)}`)
358
+
359
+ const reader = res.body!.getReader()
360
+ const decoder = new TextDecoder()
361
+ let buffer = ''
362
+ let fullText = ''
363
+ let inputTok = 0
364
+ let outputTok = 0
365
+ let cacheReadTok = 0
366
+ let cacheWriteTok = 0
367
+
368
+ try {
369
+ while( true ){
370
+ const { done, value } = await reader.read()
371
+ if( done ) break
372
+ buffer += decoder.decode( value, { stream: true })
373
+
374
+ const lines = buffer.split('\n')
375
+ buffer = lines.pop() ?? '' // last (possibly incomplete) line stays in buffer
376
+
377
+ for( const line of lines ){
378
+ if( !line.startsWith('data: ') ) continue
379
+ const raw = line.slice(6).trim()
380
+ if( raw === '[DONE]' ) break
381
+
382
+ try {
383
+ const ev = JSON.parse( raw ) as {
384
+ type: string
385
+ delta?: { type: string; text?: string; stop_reason?: string }
386
+ message?: { usage?: { input_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } }
387
+ usage?: { output_tokens: number }
388
+ }
389
+
390
+ if( ev.type === 'message_start' && ev.message?.usage ){
391
+ inputTok = ev.message.usage.input_tokens
392
+ cacheReadTok = ev.message.usage.cache_read_input_tokens ?? 0
393
+ cacheWriteTok = ev.message.usage.cache_creation_input_tokens ?? 0
394
+ }
395
+ else if( ev.type === 'content_block_delta' && ev.delta?.text ){
396
+ fullText += ev.delta.text
397
+ onChunk( ev.delta.text )
398
+ }
399
+ else if( ev.type === 'message_delta' && ev.usage?.output_tokens ){
400
+ outputTok = ev.usage.output_tokens
401
+ }
402
+ }
403
+ catch { /* ignore malformed events */ }
404
+ }
405
+ }
406
+ }
407
+ finally {
408
+ reader.releaseLock()
409
+ }
410
+
411
+ return { text: fullText, inputTok, outputTok, cacheReadTok, cacheWriteTok }
412
+ }
413
+
414
+ /**
415
+ * Call the LLM directly via fetch — no SDK, no middleware.
416
+ * Routes through withGate for concurrency limiting and 429 retry.
417
+ */
418
+ async call(
419
+ systemPrompt: string,
420
+ userMessage: string,
421
+ tick: Tick,
422
+ /** Optional sampling temperature. Omitted ⇒ the provider's default (behaviour
423
+ * unchanged). Used by the deliberate path's ideation (propose) pass to diverge. */
424
+ temperature?: number,
425
+ /** Cost-attribution tag for this call. Defaults to the master executive. */
426
+ meta: LLMCallMeta = DEFAULT_CALL_META,
427
+ ): Promise<LLMCallResult> {
428
+ const llmStart = Date.now()
429
+
430
+ // Replay re-feed takes precedence over both live and mock paths (R2-c).
431
+ const replay = this._replayCompletion( systemPrompt, userMessage, tick )
432
+ if( replay )
433
+ return { text: replay.text, inputTok: replay.inputTok, outputTok: replay.outputTok }
434
+
435
+ if( this._mock ){
436
+ const result = this._mockResponse( tick, userMessage )
437
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - llmStart, true )
438
+ return result
439
+ }
440
+
441
+ // Anthropic routes through the streaming path so the deadline is first-byte
442
+ // (TTFT), not whole-request: a long-but-healthy executive completion (often
443
+ // 20–40s on Sonnet) no longer trips the timeout mid-generation. onChunk is a
444
+ // no-op here — call() returns the full accumulated text; live token chunks go
445
+ // through callStream(). Other providers keep the whole-request deadline.
446
+ const result = await withGate(
447
+ () => this._provider === 'anthropic'
448
+ ? this._callAnthropicStream( systemPrompt, userMessage, () => {}, temperature )
449
+ : this._callProvider( systemPrompt, userMessage, temperature ),
450
+ 'executive/direct',
451
+ )
452
+
453
+ // Record token usage + cost into this Will's injected tracker (R4), tagged
454
+ // with the caller's attribution. Optional — absent on mock/replay directors.
455
+ this._track( result, meta, tick, Date.now() - llmStart, this._estPromptTokens( systemPrompt, userMessage ) )
456
+
457
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - llmStart, false )
458
+ return result
459
+ }
460
+
461
+ private _callProvider(
462
+ systemPrompt: string,
463
+ userMessage: string,
464
+ temperature?: number,
465
+ ): Promise<LLMCallResult> {
466
+ switch( this._provider ){
467
+ case 'anthropic': return this._callAnthropic( systemPrompt, userMessage, temperature )
468
+ case 'deepseek': return this._callOpenAI( systemPrompt, userMessage, temperature )
469
+ case 'openai': return this._callOpenAI( systemPrompt, userMessage, temperature )
470
+ case 'google': return this._callGoogle( systemPrompt, userMessage, temperature )
471
+ default: throw new Error(`Unknown LLM provider: ${this._provider}`)
472
+ }
473
+ }
474
+
475
+ /** Default API base URL (including version segment) for a provider. */
476
+ private _baseFor( provider: LLMProvider ): string {
477
+ switch( provider ){
478
+ case 'anthropic': return 'https://api.anthropic.com/v1'
479
+ case 'openai': return 'https://api.openai.com/v1'
480
+ case 'deepseek': return 'https://api.deepseek.com/v1'
481
+ case 'google': return 'https://generativelanguage.googleapis.com/v1beta'
482
+ }
483
+ }
484
+
485
+ /** Resolved API base: explicit override wins, else the provider default. */
486
+ private _resolvedBase(): string {
487
+ return this._baseUrl ?? this._baseFor( this._provider )
488
+ }
489
+
490
+ /**
491
+ * fetch() with a hard per-request deadline. A hung connection is aborted
492
+ * after _timeoutMs and surfaced as a clear error instead of hanging forever.
493
+ * Mirrors the embedder hardening in vector.embedder.ts.
494
+ */
495
+ private async _fetchWithTimeout( url: string, init: RequestInit ): Promise<Response> {
496
+ try {
497
+ return await fetch( url, { ...init, signal: AbortSignal.timeout( this._timeoutMs ) } )
498
+ }
499
+ catch( err ){
500
+ // AbortSignal.timeout rejects with a DOMException named 'TimeoutError'.
501
+ if( err instanceof Error && err.name === 'TimeoutError' )
502
+ throw new Error( `LLM request to ${this._provider} timed out after ${this._timeoutMs}ms` )
503
+ throw err
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Anthropic `system` field with a single prompt-cache breakpoint. The system
509
+ * prompt is fully stable per context (PromptFactory keeps every volatile section
510
+ * — including `## Current Focus` — in the user message), so one ephemeral
511
+ * breakpoint caches the whole thing: reused across master ticks and shared
512
+ * across a Will's conversation facets. GA — no beta header required.
513
+ */
514
+ private _systemField( systemPrompt: string ): Array<{ type: 'text'; text: string; cache_control: { type: 'ephemeral' } }> {
515
+ return [ { type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } } ]
516
+ }
517
+
518
+ private async _callAnthropic( systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
519
+ const body = {
520
+ model: this._model,
521
+ max_tokens: this._maxOutputTokens,
522
+ ...( temperature !== undefined ? { temperature } : {} ),
523
+ system: this._systemField( systemPrompt ),
524
+ messages: [{ role: 'user', content: userMessage }]
525
+ }
526
+
527
+ const res = await this._fetchWithTimeout(`${this._resolvedBase()}/messages`, {
528
+ method: 'POST',
529
+ headers: {
530
+ 'Content-Type': 'application/json',
531
+ 'anthropic-version': '2023-06-01',
532
+ 'x-api-key': this._apiKey
533
+ },
534
+ body: JSON.stringify( body )
535
+ })
536
+
537
+ if( !res.ok )
538
+ throw new Error(`Anthropic API ${res.status}: ${( await res.text() ).slice(0, 300)}`)
539
+
540
+ const
541
+ data = await res.json() as {
542
+ content: Array<{ type: string; text: string }>
543
+ usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
544
+ },
545
+ text = data.content.find( b => b.type === 'text' )?.text ?? ''
546
+
547
+ return {
548
+ text,
549
+ inputTok: data.usage.input_tokens,
550
+ outputTok: data.usage.output_tokens,
551
+ cacheReadTok: data.usage.cache_read_input_tokens ?? 0,
552
+ cacheWriteTok: data.usage.cache_creation_input_tokens ?? 0,
553
+ }
554
+ }
555
+
556
+ private async _callOpenAI( systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
557
+ const body = {
558
+ model: this._model,
559
+ max_completion_tokens: this._maxOutputTokens,
560
+ ...( temperature !== undefined ? { temperature } : {} ),
561
+ messages: [
562
+ { role: 'system', content: systemPrompt },
563
+ { role: 'user', content: userMessage }
564
+ ]
565
+ }
566
+
567
+ const res = await this._fetchWithTimeout(`${this._resolvedBase()}/chat/completions`, {
568
+ method: 'POST',
569
+ headers: {
570
+ 'Content-Type': 'application/json',
571
+ 'Authorization': `Bearer ${this._apiKey}`,
572
+ },
573
+ body: JSON.stringify(body),
574
+ })
575
+
576
+ if( !res.ok )
577
+ throw new Error(`OpenAI API ${res.status}: ${( await res.text() ).slice(0, 300)}`)
578
+
579
+ const
580
+ data = await res.json() as {
581
+ choices: Array<{ message: { content: string } }>
582
+ usage: { prompt_tokens: number; completion_tokens: number }
583
+ },
584
+ text = data.choices[0]?.message?.content ?? ''
585
+
586
+ return {
587
+ text,
588
+ inputTok: data.usage.prompt_tokens,
589
+ outputTok: data.usage.completion_tokens
590
+ }
591
+ }
592
+
593
+ private async _callGoogle( systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
594
+ // Gemini carries the system prompt in a dedicated `systemInstruction`
595
+ // field and the conversation in `contents`.
596
+ const body = {
597
+ systemInstruction: { parts: [ { text: systemPrompt } ] },
598
+ contents: [ { role: 'user', parts: [ { text: userMessage } ] } ],
599
+ generationConfig: {
600
+ maxOutputTokens: this._maxOutputTokens,
601
+ ...( temperature !== undefined ? { temperature } : {} ),
602
+ },
603
+ }
604
+
605
+ const res = await this._fetchWithTimeout(
606
+ `${this._resolvedBase()}/models/${this._model}:generateContent`,
607
+ {
608
+ method: 'POST',
609
+ headers: {
610
+ 'Content-Type': 'application/json',
611
+ 'x-goog-api-key': this._apiKey,
612
+ },
613
+ body: JSON.stringify( body ),
614
+ }
615
+ )
616
+
617
+ if( !res.ok )
618
+ throw new Error(`Google API ${res.status}: ${( await res.text() ).slice(0, 300)}`)
619
+
620
+ const data = await res.json() as {
621
+ candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>
622
+ usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number }
623
+ }
624
+
625
+ const text = ( data.candidates?.[ 0 ]?.content?.parts ?? [] )
626
+ .map( p => p.text ?? '' )
627
+ .join('')
628
+
629
+ return {
630
+ text,
631
+ inputTok: data.usageMetadata?.promptTokenCount ?? 0,
632
+ outputTok: data.usageMetadata?.candidatesTokenCount ?? 0,
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Write the full prompt to a debug file for inspection.
638
+ * Mirrors the original _writeDebugPrompt behavior.
639
+ */
640
+ writeDebugPrompt(
641
+ tick: Tick,
642
+ systemPrompt: string,
643
+ userMessage: string,
644
+ ): string {
645
+ try {
646
+ const debugDir = `./data/wills/${this._willId}/debug`
647
+ mkdirSync( debugDir, { recursive: true })
648
+
649
+ const totalChars = systemPrompt.length + userMessage.length
650
+ const estimatedTokens = Math.round( totalChars / 4 )
651
+
652
+ const content = [
653
+ '='.repeat(80),
654
+ `EXECUTIVE CALL — Tick ${tick}`,
655
+ '='.repeat(80),
656
+ `OUR BUILD: sys=${systemPrompt.length} chars user=${userMessage.length} chars total=~${estimatedTokens} tok`,
657
+ `PROVIDER: ${this._provider} (direct fetch — no SDK middleware)`,
658
+ `MODEL: ${this._model}`,
659
+ '='.repeat(80),
660
+ '',
661
+ `=== SYSTEM PROMPT (${systemPrompt.length} chars) ===`,
662
+ systemPrompt,
663
+ '',
664
+ `=== USER MESSAGE (${userMessage.length} chars) ===`,
665
+ userMessage,
666
+ ].join('\n')
667
+
668
+ const filepath = `${debugDir}/prompt-tick-${String( tick ).padStart( 6, '0' )}.txt`
669
+ writeFileSync( filepath, content )
670
+
671
+ logger.info(`[executive] Debug prompt written → ${filepath} (~${estimatedTokens} tok estimated)`)
672
+
673
+ return filepath
674
+ }
675
+ catch( err ){
676
+ logger.warn(`[executive] Failed to write debug prompt: ${err}`)
677
+ return ''
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Write the full LLM response text to `response-tick-N.txt` alongside the
683
+ * corresponding `prompt-tick-N.txt`. This gives developers the complete
684
+ * reasoning trace without truncation — useful for debugging planning failures.
685
+ */
686
+ writeDebugResponse(
687
+ tick: Tick,
688
+ responseText: string,
689
+ inputTok: number,
690
+ outputTok: number,
691
+ latencyMs: number,
692
+ ): void {
693
+ try {
694
+ const debugDir = `./data/wills/${this._willId}/debug`
695
+ mkdirSync( debugDir, { recursive: true })
696
+
697
+ const content = [
698
+ '='.repeat(80),
699
+ `EXECUTIVE RESPONSE — Tick ${tick}`,
700
+ '='.repeat(80),
701
+ `TOKENS: in=${inputTok} out=${outputTok} latency=${latencyMs}ms`,
702
+ `LENGTH: ${responseText.length} chars`,
703
+ '='.repeat(80),
704
+ '',
705
+ responseText,
706
+ ].join('\n')
707
+
708
+ const filepath = `${debugDir}/response-tick-${String( tick ).padStart( 6, '0' )}.txt`
709
+ writeFileSync( filepath, content )
710
+ }
711
+ catch {
712
+ /* non-critical */
713
+ }
714
+ }
715
+
716
+ }