@mindot/will 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -22
- package/dist/channels/discord.d.ts +67 -6
- package/dist/channels/discord.js +112 -6
- package/dist/channels/discord.js.map +1 -1
- package/dist/channels/whatsapp.d.ts +1 -1
- package/dist/channels/whatsapp.js +4 -1
- package/dist/channels/whatsapp.js.map +1 -1
- package/dist/cli.js +13733 -11057
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3729 -1199
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-DAW0l-lY.d.ts → will-DbDj_TEH.d.ts} +1214 -93
- package/package.json +1 -1
- package/src/channels/discord.ts +189 -11
- package/src/channels/types.ts +90 -0
- package/src/channels/whatsapp.ts +13 -4
- package/src/cli.ts +9 -4
- package/src/cognition/agency/consequence.ts +122 -1
- package/src/cognition/agency/conversation.aim.ts +260 -0
- package/src/cognition/agency/engines/action.selector.ts +85 -3
- package/src/cognition/agency/engines/affordance.synthesizer.ts +90 -1
- package/src/cognition/agency/engines/motor.schema.executor.ts +152 -10
- package/src/cognition/agency/engines/reafference.engine.ts +129 -2
- package/src/cognition/agency/proactive.communicator.ts +19 -3
- package/src/cognition/agency/reconcile.learning.ts +16 -2
- package/src/cognition/agency/restart.ts +66 -0
- package/src/cognition/agency/schemas/repertoire.ts +12 -5
- package/src/cognition/agency/selection.scoring.ts +33 -0
- package/src/cognition/agency/types.ts +35 -0
- package/src/cognition/cache/composition.ts +232 -0
- package/src/cognition/cache/deliberation.cache.ts +219 -0
- package/src/cognition/cache/fingerprint.ts +120 -0
- package/src/cognition/cache/types.ts +105 -0
- package/src/cognition/config.mirror.entities.ts +109 -1
- package/src/cognition/event.schemas.ts +22 -0
- package/src/cognition/faculties/autobiographical.narrator.ts +5 -10
- package/src/cognition/faculties/episodic.consolidator.ts +59 -3
- package/src/cognition/faculties/executive.engine/commands.ts +189 -14
- package/src/cognition/faculties/executive.engine/context.ts +67 -13
- package/src/cognition/faculties/executive.engine/deliberate.reasoning.ts +1 -1
- package/src/cognition/faculties/executive.engine/engine.ts +686 -187
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +162 -44
- package/src/cognition/faculties/executive.engine/facet.supervisor.ts +310 -65
- package/src/cognition/faculties/executive.engine/facet.ts +89 -26
- package/src/cognition/faculties/executive.engine/gating.ts +14 -14
- package/src/cognition/faculties/executive.engine/parser.ts +21 -1
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +169 -20
- package/src/cognition/faculties/executive.engine/types.ts +69 -0
- package/src/cognition/faculties/goal.manager.ts +94 -14
- package/src/cognition/faculties/known.entity.tracker.ts +267 -28
- package/src/cognition/faculties/moral.evaluator.ts +8 -3
- package/src/cognition/faculties/persona.consolidator.ts +141 -0
- package/src/cognition/faculties/reputation.tracker.ts +66 -2
- package/src/cognition/faculties/self.model.updater.ts +19 -12
- package/src/cognition/faculties/social.perception.ts +47 -3
- package/src/cognition/faculties/threat.evaluator.ts +7 -0
- package/src/cognition/faculties/working.memory.ts +10 -20
- package/src/cognition/identity.entity.ts +205 -0
- package/src/cognition/index.ts +11 -0
- package/src/cognition/memory/vector.adapter.ts +12 -3
- package/src/cognition/memory/vector.embedder.ts +54 -7
- package/src/cognition/persona.prior.ts +6 -0
- package/src/cognition/senses/audition.engine/engine.ts +404 -46
- package/src/cognition/senses/base.sense.engine.ts +1 -1
- package/src/cognition/senses/index.ts +12 -0
- package/src/cognition/social.identity.ts +273 -0
- package/src/cognition/utilities/token.tracker.ts +246 -98
- package/src/core/orchestrator.ts +38 -0
- package/src/host/boot.ts +78 -22
- package/src/index.ts +35 -0
- package/src/llm/index.ts +415 -97
- package/src/llm/routing.ts +204 -0
- package/src/llm/summarizer.ts +5 -1
- package/src/llm/wire.contracts.ts +19 -0
- package/src/pma/index.ts +67 -53
- package/src/runners/thin-shim.runner.ts +18 -6
- package/src/sdk/will.ts +121 -22
- package/src/stem/assembly.audit.ts +1 -0
- package/src/stem/guards/identity.coherence.ts +17 -6
- package/src/stem/index.ts +82 -5
- package/src/stem/mind.ts +327 -79
- package/src/stem/policy/arbiter.ts +49 -14
- package/src/stem/policy/rule.table.ts +2 -2
- package/src/stem/tracts/effector.controller.ts +56 -9
- package/src/stem/tracts/outbox.writer.ts +40 -2
- package/src/cognition/faculties/executive.engine/messages.ts +0 -102
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
* Architecture:
|
|
14
14
|
* - External call: WillManager.ingestText() → audition.ingest(TextMessage)
|
|
15
15
|
* - Percept published on bus: senses.audition.percept
|
|
16
|
-
* - Facet
|
|
16
|
+
* - Facet handoffs to the seat: executive.facet.handoff (escalation | undertaking)
|
|
17
17
|
* - GoalManager integration: automatic via executive.facet.progress (bus)
|
|
18
18
|
* - Chunk streaming: via multi-subscriber chunk callbacks (transport + SSE)
|
|
19
19
|
*
|
|
20
20
|
* The master executive is NOT involved in replies — it learns about conversations
|
|
21
21
|
* only via executive.facet.sync events published by the conversation facets.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* It takes initiative when a facet raises an `executive.facet.handoff` — the one
|
|
23
|
+
* channel every facet type uses to hand the singular seat something it owns.
|
|
24
24
|
*
|
|
25
25
|
* FocusSection.outputFormat provides a custom format that re-enables [REPLY]
|
|
26
26
|
* (normally gated out in facet mode) while removing PLANS (conversation
|
|
@@ -60,6 +60,7 @@ import type { ExecutiveEngine } from '#faculties/executive.engine'
|
|
|
60
60
|
import type { ExecutiveFacetHandle, FacetDecision } from '#faculties/executive.engine/facet'
|
|
61
61
|
import { DEFAULT_FACET_AWARENESS, type FocusSection } from '#faculties/executive.engine/prompt.factory'
|
|
62
62
|
import type { ExecutiveOutputFull } from '#faculties/executive.engine/types'
|
|
63
|
+
import { COMMUNICATE_ACTION_TYPES } from '#faculties/executive.engine/commands'
|
|
63
64
|
import type { EpisodicConsolidator } from '#faculties/episodic.consolidator'
|
|
64
65
|
import type { OutboxWriter } from '#stem/tracts/outbox.writer'
|
|
65
66
|
import { GenerativeModel } from '#cognition/generative.model'
|
|
@@ -74,6 +75,8 @@ import type {
|
|
|
74
75
|
TextMessage,
|
|
75
76
|
VoiceChunk
|
|
76
77
|
} from '#senses/index'
|
|
78
|
+
import { validateFacetHandoff, type HandoffBody } from '#faculties/executive.engine/escalation.buffer'
|
|
79
|
+
import { fnv1a } from '#agency/consequence'
|
|
77
80
|
|
|
78
81
|
// ── Internal types ─────────────────────────────────────────────
|
|
79
82
|
|
|
@@ -83,6 +86,22 @@ interface ConversationDecision {
|
|
|
83
86
|
/** Individual reply bubbles for display (separate SSE chunks). */
|
|
84
87
|
replyBubbles: string[]
|
|
85
88
|
targetEntityId: string
|
|
89
|
+
/**
|
|
90
|
+
* Actions this facet aimed at someone OTHER than the person it is talking to.
|
|
91
|
+
*
|
|
92
|
+
* A conversation facet is the mind talking to ONE person. When it decides mid-
|
|
93
|
+
* conversation to contact a third party ("I'll reach out to FKEM now"), it must
|
|
94
|
+
* not open that channel itself — a facet bound to Fabrice messaging FKEM is the
|
|
95
|
+
* parallel-conversation failure, and it would collide with any facet already
|
|
96
|
+
* talking to FKEM. So the intention is surfaced to the master, which is singular
|
|
97
|
+
* and owns whom the mind contacts.
|
|
98
|
+
*
|
|
99
|
+
* Before this existed, `extractDecision` read `output.actions` only to test for
|
|
100
|
+
* 'escalate' and dropped the rest: a third-party action was delivered nowhere,
|
|
101
|
+
* became no intent, competed in nothing and left no reafference — the mind said
|
|
102
|
+
* it would make contact, believed it had, and nothing ever went out.
|
|
103
|
+
*/
|
|
104
|
+
outwardIntents?: { target: string; gist?: string; reasoning?: string }[]
|
|
86
105
|
newGoals?: ExecutiveOutputFull['newGoals']
|
|
87
106
|
goalsToAbandon?: ExecutiveOutputFull['goalsToAbandon']
|
|
88
107
|
newBeliefs?: ExecutiveOutputFull['newBeliefs']
|
|
@@ -114,6 +133,46 @@ interface CoalesceWindow {
|
|
|
114
133
|
}
|
|
115
134
|
|
|
116
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Split a facet's actions into the ones aimed at the person it is talking to
|
|
138
|
+
* (delivered as the reply, which the [REPLY_TEXT] block already carries) and the
|
|
139
|
+
* ones aimed at someone else (returned here, for the master to own).
|
|
140
|
+
*
|
|
141
|
+
* "Aimed at this person" is matched against both the bound keid and the name the
|
|
142
|
+
* facet was given for them, because a mind writes whichever it is looking at —
|
|
143
|
+
* the trace shows it using `discord:1019…` and `Fabrice` interchangeably within a
|
|
144
|
+
* single decision. Anything else — including a name the mind has heard but never
|
|
145
|
+
* bound to anyone — is outward, and gets carried rather than dropped: whether it
|
|
146
|
+
* can be reached at all is the master's problem to notice, not this partition's.
|
|
147
|
+
*/
|
|
148
|
+
export function partitionOutwardIntents(
|
|
149
|
+
actions: ExecutiveOutputFull['actions'] | undefined,
|
|
150
|
+
boundKeid: string,
|
|
151
|
+
boundName: string,
|
|
152
|
+
): { target: string; gist?: string; reasoning?: string }[] {
|
|
153
|
+
const mine = new Set( [ boundKeid, boundName ].map( s => s.trim().toLowerCase() ).filter( Boolean ) )
|
|
154
|
+
const out: { target: string; gist?: string; reasoning?: string }[] = []
|
|
155
|
+
|
|
156
|
+
for( const action of actions ?? [] ){
|
|
157
|
+
if( !COMMUNICATE_ACTION_TYPES.has( action.type.toLowerCase() ) ) continue
|
|
158
|
+
|
|
159
|
+
const args = ( action.args && typeof action.args === 'object' ? action.args : {} ) as Record<string, unknown>
|
|
160
|
+
const target = [ action.target, args['to'], args['recipient'], args['target'] ]
|
|
161
|
+
.find( v => typeof v === 'string' && v.trim().length > 0 ) as string | undefined
|
|
162
|
+
|
|
163
|
+
// No addressee named at all ⇒ it meant the person in front of it; the
|
|
164
|
+
// [REPLY_TEXT] block is already that reply.
|
|
165
|
+
if( !target || mine.has( target.trim().toLowerCase() ) ) continue
|
|
166
|
+
|
|
167
|
+
const gist = [ args['content'], args['message'], args['text'], args['body'] ]
|
|
168
|
+
.find( v => typeof v === 'string' && v.trim().length > 0 ) as string | undefined
|
|
169
|
+
|
|
170
|
+
out.push({ target: target.trim(), ...( gist ? { gist } : {} ), ...( action.reasoning ? { reasoning: action.reasoning } : {} ) })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
117
176
|
// ── Conversation output format ─────────────────────────────────
|
|
118
177
|
// Two-step format: JSON first (private reasoning), then [REPLY_TEXT] (streamed to client).
|
|
119
178
|
// [REPLY_TEXT] is plain prose — no JSON wrapper, no targetEntityId needed.
|
|
@@ -145,25 +204,38 @@ Start a new paragraph (blank line) to send a separate chat bubble.
|
|
|
145
204
|
Write [REPLY_TEXT] AFTER the closing \`\`\`. This is the only part the speaker sees — keep it grounded, present.
|
|
146
205
|
Separate multiple messages with a blank line for natural conversational pauses (like separate texts).
|
|
147
206
|
|
|
207
|
+
## Saying nothing
|
|
208
|
+
Silence is a real choice and it is available to me: if I have nothing to say right now — I am
|
|
209
|
+
waiting on them, or speaking again would only repeat myself — I write a [NO_MESSAGE] block
|
|
210
|
+
instead of a [REPLY_TEXT] one, and I put my reason inside it:
|
|
211
|
+
|
|
212
|
+
[NO_MESSAGE]
|
|
213
|
+
Nothing new to add — I am waiting on their answer to what I already asked.
|
|
214
|
+
[/NO_MESSAGE]
|
|
215
|
+
|
|
216
|
+
That is recorded and NEVER sent. Anything between the [REPLY_TEXT] markers IS SENT, so a line
|
|
217
|
+
like "[no message this cycle — waiting for their reply]" does not describe my silence to
|
|
218
|
+
myself, it delivers that sentence to them. If I write both blocks, the silence wins.
|
|
219
|
+
|
|
148
220
|
## When to use GOALS_NEW (almost always)
|
|
149
221
|
If the speaker requests, mentions, or implies something I should follow through on — embed [GOALS_NEW] in my reasoning.
|
|
150
|
-
This tracks intent across future cycles
|
|
222
|
+
This tracks intent across future cycles on its own.
|
|
151
223
|
|
|
152
224
|
## When to use the escalate action (rare — only for multi-step tasks)
|
|
153
|
-
Use \`{"type": "escalate", "reasoning": "...", "expectedOutcome": "..."}\` in actions ONLY when the request genuinely
|
|
225
|
+
Use \`{"type": "escalate", "reasoning": "...", "expectedOutcome": "..."}\` in actions ONLY when the request genuinely needs a plan I carry out over time rather than an answer I can give now:
|
|
154
226
|
- The task involves multiple steps across future cycles ("build me X", "monitor Y", "set up Z")
|
|
155
227
|
- The request changes my active goal priorities in a significant way
|
|
156
228
|
- I need to coordinate something beyond a single reply
|
|
157
229
|
|
|
158
|
-
**The "reasoning" field on the escalate action becomes the
|
|
230
|
+
**The "reasoning" field on the escalate action becomes the description of the work I pick up.**
|
|
159
231
|
Make it concrete — describe WHAT needs to happen, not just that I am escalating.
|
|
160
232
|
Good: type=escalate, reasoning="User wants weekly mood summaries by email every Monday. Needs: data aggregation, schedule, email delivery.", expectedOutcome="Weekly email delivered."
|
|
161
233
|
Bad: type=escalate, reasoning="Escalating because this is complex."
|
|
162
234
|
|
|
163
235
|
When I escalate:
|
|
164
236
|
1. STILL include a [REPLY_TEXT] that acknowledges the request (e.g. "Got it — I'm on it.")
|
|
165
|
-
2.
|
|
166
|
-
3. Do NOT include a [PLANS] block —
|
|
237
|
+
2. I form and carry out the plan away from this conversation, over the cycles that follow
|
|
238
|
+
3. Do NOT include a [PLANS] block — the planning happens there, not here
|
|
167
239
|
|
|
168
240
|
For simple, single-exchange requests (questions, opinions, short tasks) — do NOT escalate. Just reply.`
|
|
169
241
|
|
|
@@ -270,6 +342,12 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
270
342
|
* other percept uses. Wired to `stateManager.setEntity` in assembleMind().
|
|
271
343
|
*/
|
|
272
344
|
private _memorySink: (( entity: MemoryEntity ) => void) | null = null
|
|
345
|
+
/**
|
|
346
|
+
* Sim tick of the most recent facet decision — the only deterministic clock this
|
|
347
|
+
* off-tick engine has. Stamped from `FacetDecision.tick`, and used to key the
|
|
348
|
+
* conversation records it writes into state.
|
|
349
|
+
*/
|
|
350
|
+
private _lastDecisionTick = 0
|
|
273
351
|
/** Speaker attachment strength accessor (0–1) — weights salience by relationship. */
|
|
274
352
|
private _getAttachmentScore: (( entityId: string ) => number) | null = null
|
|
275
353
|
/** Active-goal topic text accessor — for salience topic-overlap. */
|
|
@@ -278,6 +356,8 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
278
356
|
private _inflightInbound = new Map<string, string>()
|
|
279
357
|
/** In-flight thread per entity — stamps chunk envelopes with the current threadId. */
|
|
280
358
|
private _inflightThread = new Map<string, string>()
|
|
359
|
+
/** Targets with an outreach being composed right now — see authorOutreach. */
|
|
360
|
+
private _outreachInFlight = new Set<string>()
|
|
281
361
|
|
|
282
362
|
// ── Assembly wiring ─────────────────────────────────────────
|
|
283
363
|
|
|
@@ -331,11 +411,11 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
331
411
|
|
|
332
412
|
// ── CognitiveEngine ─────────────────────────────────────────
|
|
333
413
|
|
|
334
|
-
/** Override: audition adds the master
|
|
414
|
+
/** Override: audition adds the facet→master handoff to the base percept schema. */
|
|
335
415
|
publishes(): CognitiveEventSchema[] {
|
|
336
416
|
return [
|
|
337
|
-
{ type: 'senses.audition.percept',
|
|
338
|
-
{ type: '
|
|
417
|
+
{ type: 'senses.audition.percept', version: 1, validate: () => null },
|
|
418
|
+
{ type: 'executive.facet.handoff', version: 1, validate: validateFacetHandoff },
|
|
339
419
|
]
|
|
340
420
|
}
|
|
341
421
|
// subscribes() and onCognitiveEvent() inherit the base no-ops (ingest-driven).
|
|
@@ -506,6 +586,16 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
506
586
|
this._inflightInbound.set( entityId, content )
|
|
507
587
|
this._inflightThread.set( entityId, threadId )
|
|
508
588
|
|
|
589
|
+
// Someone spoke to us — record it in state so SOCIAL COGNITION can see it.
|
|
590
|
+
// Until this, an inbound message existed only on the bus and inside a facet:
|
|
591
|
+
// it created no entity, so SocialPerception (whose whole job is to notice
|
|
592
|
+
// people acting toward us) had nothing to scan, never published
|
|
593
|
+
// `interaction.occurred`, and every consumer of that event — reputation,
|
|
594
|
+
// affect, theory-of-mind, attachment, frustration — learned nothing from any
|
|
595
|
+
// conversation the Will ever had. A Will could hold 27 exchanges with someone
|
|
596
|
+
// and still carry familiarity 0, valence 0 for them.
|
|
597
|
+
this._writeReceived( entityId, speakerName, content, threadId )
|
|
598
|
+
|
|
509
599
|
// ── Route to facet, then block the entity queue until the turn resolves ──
|
|
510
600
|
// The turn deferred must be armed BEFORE routing because a synchronous facet
|
|
511
601
|
// (e.g. a test mock) can fire its decision during report().
|
|
@@ -561,7 +651,10 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
561
651
|
let handle = this._facets.get( percept.speakerEntityId )
|
|
562
652
|
if( !handle ){
|
|
563
653
|
// New conversation session — try to spawn a facet.
|
|
564
|
-
|
|
654
|
+
// Keyed by speaker: one thread of attention per person. The supervisor now
|
|
655
|
+
// owns that guarantee (and carries the thread's reasoning across a reap),
|
|
656
|
+
// so a re-spawn after an idle gap resumes rather than starting cold.
|
|
657
|
+
const result = this._executiveEngine.spawnFacet('conversation', `conversation:${percept.speakerEntityId}`)
|
|
565
658
|
if( result.attention === 'full' || !result.handle ){
|
|
566
659
|
logger.warn(
|
|
567
660
|
`[audition-engine] Executive attention full — ` +
|
|
@@ -691,6 +784,11 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
691
784
|
awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
|
|
692
785
|
awarenessEntityId: percept.speakerEntityId,
|
|
693
786
|
|
|
787
|
+
// Who this facet is with — reported to the master on every facet sync so the
|
|
788
|
+
// singular seat knows whose conversations these are, not just how many.
|
|
789
|
+
subjectEntityId: percept.speakerEntityId,
|
|
790
|
+
subjectName: speakerName,
|
|
791
|
+
|
|
694
792
|
instructions: [
|
|
695
793
|
'I am in a live conversation with this person. I respond as myself.',
|
|
696
794
|
'I stay grounded in my real memories and feelings — I do not invent experiences I have no record of.',
|
|
@@ -704,15 +802,28 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
704
802
|
// paragraphs (double-newline separated) become separate reply bubbles.
|
|
705
803
|
extractDecision: ( raw: unknown ): ConversationDecision => {
|
|
706
804
|
const output = raw as ExecutiveOutputFull
|
|
707
|
-
|
|
805
|
+
// A declared silence suppresses the words and NOTHING else — the goals,
|
|
806
|
+
// beliefs and entity updates below are things the mind worked out from
|
|
807
|
+
// what it heard, and they are true whether or not it answers. Discarding
|
|
808
|
+
// them with the reply would make choosing silence cost the mind its
|
|
809
|
+
// learning, which is a reason not to choose it.
|
|
810
|
+
const silent = output.noMessage !== undefined
|
|
811
|
+
if( silent )
|
|
812
|
+
logger.info(`[audition-engine] chose silence toward ${ speakerName ?? percept.speakerEntityId }: ${ output.noMessage!.slice( 0, 120 ) }`)
|
|
813
|
+
const rawReply = silent ? '' : ( output.replyText?.trim() ?? '')
|
|
708
814
|
const bubbles = rawReply.split( /\n{2,}/ )
|
|
709
815
|
.map( b => b.trim() )
|
|
710
816
|
.filter( Boolean )
|
|
711
817
|
|
|
818
|
+
const outwardIntents = partitionOutwardIntents(
|
|
819
|
+
output.actions, percept.speakerEntityId, speakerName,
|
|
820
|
+
)
|
|
821
|
+
|
|
712
822
|
return {
|
|
713
823
|
reply: bubbles.join('\n'),
|
|
714
824
|
replyBubbles: bubbles,
|
|
715
825
|
targetEntityId: percept.speakerEntityId,
|
|
826
|
+
...( outwardIntents.length > 0 ? { outwardIntents } : {} ),
|
|
716
827
|
newGoals: output.newGoals,
|
|
717
828
|
goalsToAbandon: output.goalsToAbandon,
|
|
718
829
|
newBeliefs: output.newBeliefs,
|
|
@@ -736,31 +847,95 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
736
847
|
*/
|
|
737
848
|
async authorOutreach( entityId: string, entityName: string, gist?: string ): Promise<string[]> {
|
|
738
849
|
if( !this._executiveEngine ) return []
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
850
|
+
|
|
851
|
+
// One authoring pass per person at a time.
|
|
852
|
+
//
|
|
853
|
+
// This was unguarded, and the agency can hold more than one intent toward the
|
|
854
|
+
// same target at once (two undertakings, or an undertaking plus a self-initiated
|
|
855
|
+
// reach). Each one spawned its own transient facet, each facet independently
|
|
856
|
+
// composed a message, and both were delivered — the same question asked twice,
|
|
857
|
+
// reworded, seconds apart. The executor's idempotence was keyed by INTENT id,
|
|
858
|
+
// which cannot see that two intents mean one conversation.
|
|
859
|
+
//
|
|
860
|
+
// A concurrent second call returns empty rather than waiting: its intent stays
|
|
861
|
+
// 'awaiting' and comes back round once this pass has landed and satiation has
|
|
862
|
+
// had a chance to read it, which is the outcome we want anyway.
|
|
863
|
+
if( this._outreachInFlight.has( entityId ) ){
|
|
864
|
+
logger.info(`[audition-engine] already composing an outreach to ${ entityId } — not opening a second`)
|
|
742
865
|
return []
|
|
743
866
|
}
|
|
744
|
-
const handle = spawned.handle
|
|
745
867
|
|
|
746
|
-
|
|
868
|
+
// Already talking to them? Then this is not a second thread — it is a thing to
|
|
869
|
+
// say in the one that is open, and it must be said BY that thread.
|
|
870
|
+
//
|
|
871
|
+
// A transient facet composing in parallel cannot see the live conversation: not
|
|
872
|
+
// the thread digest, not what was said two minutes ago, not the thinking the
|
|
873
|
+
// open facet has been doing about this person. So the mind asked the same
|
|
874
|
+
// question it had already asked, in different words, while the answer was
|
|
875
|
+
// sitting in a thread it was not reading. Routing through the open facet costs
|
|
876
|
+
// nothing extra — the focus rides on the REPORT (see FacetReport.focus), so the
|
|
877
|
+
// conversation's own standing focus is never touched and the next inbound turn
|
|
878
|
+
// resumes exactly where it was.
|
|
879
|
+
const openThread = this._executiveEngine.facetFor(`conversation:${ entityId }`)
|
|
880
|
+
const handle = openThread ?? ( () => {
|
|
881
|
+
// Nobody home — a transient authoring facet, deliberately NOT supervisor-keyed:
|
|
882
|
+
// it is exactly what the mind can most afford to evict under pressure, and a
|
|
883
|
+
// key would move it into the protected tier alongside live conversations.
|
|
884
|
+
const spawned = this._executiveEngine!.spawnFacet('outreach')
|
|
885
|
+
if( spawned.attention === 'full' || !spawned.handle ){
|
|
886
|
+
logger.warn(`[audition-engine] facet budget full — cannot author outreach to ${ entityId }`)
|
|
887
|
+
return undefined
|
|
888
|
+
}
|
|
889
|
+
return spawned.handle
|
|
890
|
+
} )()
|
|
891
|
+
|
|
892
|
+
if( !handle ) return []
|
|
893
|
+
|
|
894
|
+
if( openThread )
|
|
895
|
+
logger.info(`[audition-engine] composing outreach to ${ entityId } inside the open conversation (${ openThread.facetId })`)
|
|
896
|
+
|
|
897
|
+
// What we have already said to each other, when there IS an open thread. Without
|
|
898
|
+
// it the mind opens with a question it asked four minutes ago.
|
|
899
|
+
const digest = openThread ? this._digests.getDigest( this._inflightThread.get( entityId ) ?? entityId ) : ''
|
|
900
|
+
|
|
901
|
+
const outreachFocus: FocusSection = ({
|
|
747
902
|
title: 'Reaching out',
|
|
748
903
|
function: 'outreach',
|
|
749
904
|
content: [
|
|
750
|
-
|
|
751
|
-
|
|
905
|
+
openThread
|
|
906
|
+
? `I am already in conversation with ${ entityName } (id: ${ entityId }), and there is something I have decided to say to them now — unprompted, not an answer to anything they asked.`
|
|
907
|
+
: `I have decided, on my own initiative, to reach out to ${ entityName } (id: ${ entityId }).`,
|
|
908
|
+
openThread
|
|
909
|
+
? 'This continues that conversation. I do not re-introduce myself and I do not ask again for something already answered above.'
|
|
910
|
+
: 'No one prompted this — I am choosing to make contact now.',
|
|
911
|
+
digest,
|
|
752
912
|
gist ? `What is on my mind: ${ gist }` : '',
|
|
913
|
+
// The gist is what the MASTER framed, and the master was not talking to
|
|
914
|
+
// them — so it refers to people in the third person, including sometimes
|
|
915
|
+
// the very person about to read it. Observed live: "Fabrice says the
|
|
916
|
+
// server issue is fixed now and he should look into the logs", addressed
|
|
917
|
+
// TO him. The words are mine to choose; the gist is only what I mean.
|
|
918
|
+
gist ? `That is my sense of it, not my words to them — I am speaking to ${ entityName } directly, so I say it the way I would say it to their face.` : '',
|
|
753
919
|
].filter( Boolean ).join('\n'),
|
|
754
920
|
recallQuery: gist ?? entityName,
|
|
755
921
|
awareness: [ ...DEFAULT_FACET_AWARENESS, 'plans' ],
|
|
756
922
|
awarenessEntityId: entityId,
|
|
923
|
+
subjectEntityId: entityId,
|
|
924
|
+
subjectName: entityName,
|
|
757
925
|
instructions:
|
|
758
926
|
'Considering who I am, my goals, and how I feel, I say what I genuinely want to say to ' +
|
|
759
927
|
'them now. I speak as myself; I stay grounded in my real memories — I do not invent experiences ' +
|
|
760
928
|
'I have no record of.',
|
|
761
929
|
outputFormat: CONVERSATION_OUTPUT_FORMAT,
|
|
762
930
|
extractDecision: ( raw: unknown ): ConversationDecision => {
|
|
763
|
-
const output
|
|
931
|
+
const output = raw as ExecutiveOutputFull
|
|
932
|
+
// A declared silence beats anything else in the response. Unprompted
|
|
933
|
+
// speech is the one case where saying nothing must be cheaper than
|
|
934
|
+
// saying something — nobody is waiting on this.
|
|
935
|
+
if( output.noMessage !== undefined ){
|
|
936
|
+
logger.info(`[audition-engine] chose not to reach out to ${ entityName }: ${ output.noMessage.slice( 0, 120 ) }`)
|
|
937
|
+
return { reply: '', replyBubbles: [], targetEntityId: entityId, requiresMasterAttention: false }
|
|
938
|
+
}
|
|
764
939
|
const rawReply = output.replyText?.trim() ?? ''
|
|
765
940
|
const bubbles = rawReply.split( /\n{2,}/ ).map( b => b.trim() ).filter( Boolean )
|
|
766
941
|
return { reply: bubbles.join('\n'), replyBubbles: bubbles, targetEntityId: entityId, requiresMasterAttention: false }
|
|
@@ -770,24 +945,50 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
770
945
|
// report() only QUEUES the facet's reasoning; the authored bubbles arrive LATER
|
|
771
946
|
// via the subscription. So wait for the DECISION (not report's resolution), with
|
|
772
947
|
// a safety timeout, then tear the transient facet down.
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
() => {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
948
|
+
this._outreachInFlight.add( entityId )
|
|
949
|
+
try {
|
|
950
|
+
const bubbles = await new Promise<string[]>( resolve => {
|
|
951
|
+
let settled = false
|
|
952
|
+
let unsub: () => void = () => {}
|
|
953
|
+
let timer: ReturnType<typeof setTimeout>
|
|
954
|
+
const done = ( b: string[] ): void => { if( settled ) return; settled = true; clearTimeout( timer ); unsub(); resolve( b ) }
|
|
955
|
+
timer = setTimeout(
|
|
956
|
+
() => { logger.warn(`[audition-engine] outreach authoring timed out for ${ entityId }`); done( [] ) },
|
|
957
|
+
60_000, // generous: the facet LLM authors in ~8–18s
|
|
958
|
+
)
|
|
959
|
+
// ONLY this report's decision. Sharing a live conversation facet means its
|
|
960
|
+
// ordinary reply decisions arrive on the same subscription, and resolving
|
|
961
|
+
// on one of those would hand the human's reply back as if the mind had
|
|
962
|
+
// composed it unprompted — and deliver it twice.
|
|
963
|
+
unsub = handle.subscribe( d => {
|
|
964
|
+
if( d.respondingToType !== 'outreach') return
|
|
965
|
+
done( ( d.decision as ConversationDecision ).replyBubbles ?? [] )
|
|
966
|
+
} )
|
|
967
|
+
// The focus rides the REPORT, so a shared conversation facet keeps its own
|
|
968
|
+
// standing focus and its next inbound turn resumes untouched.
|
|
969
|
+
Promise.resolve( handle.report({ type: 'outreach', payload: { entityId, gist }, focus: outreachFocus }) ).catch( err => {
|
|
970
|
+
logger.warn(`[audition-engine] outreach report failed for ${ entityId }: ${ ( err as Error ).message }`)
|
|
971
|
+
done( [] )
|
|
972
|
+
} )
|
|
786
973
|
} )
|
|
787
|
-
} )
|
|
788
974
|
|
|
789
|
-
|
|
790
|
-
|
|
975
|
+
// Only tear down what we opened. Destroying a borrowed conversation facet
|
|
976
|
+
// would end the conversation as a side effect of speaking in it.
|
|
977
|
+
if( !openThread ) handle.destroy()
|
|
978
|
+
return bubbles
|
|
979
|
+
}
|
|
980
|
+
catch( err ){
|
|
981
|
+
// A facet that throws is a pass that produced no words — the same outcome as
|
|
982
|
+
// the timeout, and the caller's contract is already "empty means I could not
|
|
983
|
+
// author". Letting it escape would reject inside MotorSchemaExecutor's
|
|
984
|
+
// fire-and-forget authoring chain instead.
|
|
985
|
+
logger.warn(`[audition-engine] outreach authoring failed for ${ entityId }: ${ ( err as Error ).message }`)
|
|
986
|
+
return []
|
|
987
|
+
}
|
|
988
|
+
// finally{} on every path — the timeout resolves empty rather than throwing, but
|
|
989
|
+
// a destroy() or report() that throws must not leave this person permanently
|
|
990
|
+
// un-reachable by leaving the guard set.
|
|
991
|
+
finally { this._outreachInFlight.delete( entityId ) }
|
|
791
992
|
}
|
|
792
993
|
|
|
793
994
|
// ── Conversation memory (Section 5) ─────────────────────────
|
|
@@ -801,6 +1002,116 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
801
1002
|
* (Section 1.2) routes ingest through the tick loop. The entity carries no
|
|
802
1003
|
* wall-clock timestamp — `setEntity` stamps createdAt/tick from the sim clock.
|
|
803
1004
|
*/
|
|
1005
|
+
/**
|
|
1006
|
+
* The inbound as a social signal in state — mirror of `conversation.sent`.
|
|
1007
|
+
*
|
|
1008
|
+
* Shaped for `SocialPerception._scanSocialSignals`, which reads `sourceKeid` for
|
|
1009
|
+
* who acted and `directedAtSelf` for whether it was aimed at us. Valence is left
|
|
1010
|
+
* UNSET on purpose: the words have not been appraised yet, and guessing a number
|
|
1011
|
+
* here would feed reputation and affect a sentiment nobody measured. Absent, the
|
|
1012
|
+
* scanner falls back to its neutral default, so the Will learns *that* someone
|
|
1013
|
+
* engaged (familiarity, recency, reliability) without inventing how it felt.
|
|
1014
|
+
*/
|
|
1015
|
+
/**
|
|
1016
|
+
* A durable, deterministic id for a conversation record.
|
|
1017
|
+
*
|
|
1018
|
+
* `<prefix>-<entity>-<tick>-<hash of the words>`. Every part earns its place:
|
|
1019
|
+
* • entity — whose conversation this is;
|
|
1020
|
+
* • tick — WHEN, from the sim clock, which resumes from the snapshot and so
|
|
1021
|
+
* keeps rising across restarts;
|
|
1022
|
+
* • hash — which utterance, so two things said to one person on one tick stay
|
|
1023
|
+
* two records.
|
|
1024
|
+
*
|
|
1025
|
+
* What it replaces was `<prefix>-<entity>-<N>` with N a process-local counter.
|
|
1026
|
+
* It restarted at 1 on every boot, so each session OVERWROTE the previous
|
|
1027
|
+
* session's records of the same person — a mind that had spoken with someone
|
|
1028
|
+
* across four restarts held one session's worth of evidence that it ever had.
|
|
1029
|
+
* Found by diffing a live snapshot against the Discord transcript it came from:
|
|
1030
|
+
* `conv-sent-reply-discord:1019…-1` held that morning's greeting, and every
|
|
1031
|
+
* earlier conversation keyed to the same id was simply gone.
|
|
1032
|
+
*
|
|
1033
|
+
* No wallClock: these ids live in state, and a wall-clock id makes the recorded
|
|
1034
|
+
* and replayed runs diverge (R2).
|
|
1035
|
+
*/
|
|
1036
|
+
private _sentKey( prefix: string, entityId: string, words: string ): string {
|
|
1037
|
+
return `${ prefix }-${ entityId }-${ this._lastDecisionTick }-${ fnv1a( words ) }`
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private _writeReceived( entityId: string, speakerName: string | undefined, content: string, threadId: string ): void {
|
|
1041
|
+
if( !this._memorySink ) return
|
|
1042
|
+
// Never wallClock(): this entity LIVES IN STATE, so a wall-clock id makes the
|
|
1043
|
+
// recorded and replayed runs diverge (R2). Observed as a replay consuming 17 of
|
|
1044
|
+
// 18 recorded completions — different ids meant different percepts meant a
|
|
1045
|
+
// different executive firing schedule.
|
|
1046
|
+
//
|
|
1047
|
+
// But nor a process-local counter, which is what this was. `conv-received-<id>-N`
|
|
1048
|
+
// restarted at N=1 on every boot, so each session silently OVERWROTE the last
|
|
1049
|
+
// session's records of the same person. A mind that had spoken with someone
|
|
1050
|
+
// across four restarts held one session's worth of evidence that it ever had.
|
|
1051
|
+
// See _sentKey.
|
|
1052
|
+
this._memorySink({
|
|
1053
|
+
id: this._sentKey('conv-received', entityId, content ),
|
|
1054
|
+
type: 'conversation.received',
|
|
1055
|
+
metadata: {
|
|
1056
|
+
sourceKeid: entityId,
|
|
1057
|
+
sourceName: speakerName,
|
|
1058
|
+
directedAtSelf: true, // an inbound turn is addressed to us by definition
|
|
1059
|
+
action: 'communication',
|
|
1060
|
+
preview: content.slice( 0, 140 ),
|
|
1061
|
+
chars: content.length,
|
|
1062
|
+
...( threadId ? { threadId } : {} ),
|
|
1063
|
+
},
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Record that the mind SPOKE to someone, mirroring `_writeReceived`.
|
|
1069
|
+
*
|
|
1070
|
+
* Only ProactiveCommunicator wrote `conversation.sent`, so a reply — which is
|
|
1071
|
+
* most of what a Will says — left no durable trace of having spoken. Everything
|
|
1072
|
+
* that asks "have I already said something to them?" was therefore blind to
|
|
1073
|
+
* conversation: satiation could not damp repeating a relay delivered as a reply,
|
|
1074
|
+
* and an undertaking discharged inside a conversation stayed forever unkept,
|
|
1075
|
+
* which is exactly how the same message went out again and again.
|
|
1076
|
+
*
|
|
1077
|
+
* Speaking is speaking, whichever path carried it.
|
|
1078
|
+
*/
|
|
1079
|
+
private _writeSent(
|
|
1080
|
+
entityId: string,
|
|
1081
|
+
entityName: string | undefined,
|
|
1082
|
+
bubbles: string[],
|
|
1083
|
+
/**
|
|
1084
|
+
* Outbox ids for these bubbles — the ONLY thing that lets a later delivery
|
|
1085
|
+
* ack find this record (`OutboxController.confirmDelivery` correlates on
|
|
1086
|
+
* `outboxMessageIds`, there is no other key).
|
|
1087
|
+
*
|
|
1088
|
+
* Omitted, this record could never be marked delivered. Every reply the mind
|
|
1089
|
+
* ever made carried `delivered` unset, forever — so a mind asking itself "did
|
|
1090
|
+
* that land?" found no answer for anything it had SAID, while the answer was
|
|
1091
|
+
* recorded faithfully for everything it had initiated. Silence read exactly
|
|
1092
|
+
* like failure, and it re-sent. The proactive path stored these from the
|
|
1093
|
+
* start; the reply path was simply never given them.
|
|
1094
|
+
*/
|
|
1095
|
+
outboxMessageIds?: string[],
|
|
1096
|
+
): void {
|
|
1097
|
+
if( !this._memorySink || bubbles.length === 0 ) return
|
|
1098
|
+
this._memorySink({
|
|
1099
|
+
id: this._sentKey('conv-sent-reply', entityId, bubbles.join('\n') ),
|
|
1100
|
+
type: 'conversation.sent',
|
|
1101
|
+
metadata: {
|
|
1102
|
+
targetEntityId: entityId,
|
|
1103
|
+
targetEntityName: entityName,
|
|
1104
|
+
messageCount: bubbles.length,
|
|
1105
|
+
preview: bubbles[0]?.slice( 0, 100 ) ?? '',
|
|
1106
|
+
effectorName: 'text',
|
|
1107
|
+
source: 'audition-facet',
|
|
1108
|
+
tick: this._lastDecisionTick,
|
|
1109
|
+
delivered: false,
|
|
1110
|
+
...( outboxMessageIds?.length ? { outboxMessageIds } : {} ),
|
|
1111
|
+
},
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
804
1115
|
private _persistExchangeMemory( entityId: string, threadId: string, reply: string, confidence: number, entityName?: string ): void {
|
|
805
1116
|
const inbound = this._inflightInbound.get( entityId ) ?? ''
|
|
806
1117
|
this._inflightInbound.delete( entityId )
|
|
@@ -832,6 +1143,19 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
832
1143
|
// facet's spawn-time thread — correct for an entity that spans threads (§2).
|
|
833
1144
|
const threadId = this._inflightThread.get( entityId ) ?? ''
|
|
834
1145
|
|
|
1146
|
+
// The sim tick this was reasoned at — the only deterministic clock an off-tick
|
|
1147
|
+
// engine has, and what the conversation-record ids are keyed on.
|
|
1148
|
+
this._lastDecisionTick = decision.tick ?? this._lastDecisionTick
|
|
1149
|
+
|
|
1150
|
+
// An outreach composed INSIDE this conversation (authorOutreach borrowing the
|
|
1151
|
+
// open facet) lands here too, because the session subscription is facet-wide.
|
|
1152
|
+
// Its words belong to `authorOutreach`, which returns them to the agency for
|
|
1153
|
+
// delivery through the proactive path; delivering them here as well would send
|
|
1154
|
+
// the same message twice and answer a turn nobody took. `_endTurn` is not
|
|
1155
|
+
// called either — an outreach is not a turn, and releasing the queue here would
|
|
1156
|
+
// let the next inbound start while a real turn was still in flight.
|
|
1157
|
+
if( decision.respondingToType === 'outreach') return
|
|
1158
|
+
|
|
835
1159
|
// finally{} releases the entity's serial turn queue on EVERY exit path —
|
|
836
1160
|
// reply delivered, reply suppressed, or escalation only. Without this a
|
|
837
1161
|
// suppressed-reply early-return would stall the queue until the safety timeout.
|
|
@@ -875,6 +1199,8 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
875
1199
|
pushToOutbox: !viaTransport,
|
|
876
1200
|
})
|
|
877
1201
|
|
|
1202
|
+
this._writeSent( entityId, d.targetEntityId, d.replyBubbles, ids )
|
|
1203
|
+
|
|
878
1204
|
if( viaTransport )
|
|
879
1205
|
logger.info(
|
|
880
1206
|
`[audition-engine] Reply emitted via transport for ${entityId} ` +
|
|
@@ -892,22 +1218,54 @@ export class AuditionEngine extends BaseSenseEngine {
|
|
|
892
1218
|
)
|
|
893
1219
|
}
|
|
894
1220
|
|
|
895
|
-
// ──
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1221
|
+
// ── Outward intentions ────────────────────────────────────
|
|
1222
|
+
// The facet decided, mid-conversation, to say something to someone ELSE.
|
|
1223
|
+
// It does not do that itself (see ConversationDecision.outwardIntents) — it
|
|
1224
|
+
// hands the intention to the master, which is singular and owns whom the
|
|
1225
|
+
// mind contacts. The master perceives it as an undertaking it made and
|
|
1226
|
+
// decides whether it still means it; nothing here forces the contact.
|
|
1227
|
+
// `executive.facet.handoff` is the ONE channel every facet type uses to hand
|
|
1228
|
+
// the master something. It replaced `audition.task.signal`, which was named
|
|
1229
|
+
// and typed for this engine alone — see EscalationBuffer.
|
|
1230
|
+
const handoff = ( body: HandoffBody ): void => {
|
|
1231
|
+
this._bus?.publish({
|
|
1232
|
+
type: 'executive.facet.handoff',
|
|
901
1233
|
version: 1,
|
|
902
1234
|
sourceEngine: this.name,
|
|
903
1235
|
salience: 0.9,
|
|
904
1236
|
payload: {
|
|
905
|
-
|
|
1237
|
+
facetId: decision.facetId,
|
|
1238
|
+
subjectEntityId: entityId,
|
|
1239
|
+
...( d.targetEntityId ? { subjectName: d.targetEntityId } : {} ),
|
|
906
1240
|
threadId,
|
|
907
|
-
|
|
908
|
-
|
|
1241
|
+
confidence: decision.confidence,
|
|
1242
|
+
// No `tick` — this engine runs off-tick and has no honest sim clock of
|
|
1243
|
+
// its own. The master stamps it from the tick the handoff ARRIVES on,
|
|
1244
|
+
// which is within one tick of when it was formed and, crucially, is a
|
|
1245
|
+
// real clock reading rather than the last time the master happened to run.
|
|
1246
|
+
body,
|
|
909
1247
|
}
|
|
910
1248
|
})
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if( d.outwardIntents?.length )
|
|
1252
|
+
for( const intent of d.outwardIntents ){
|
|
1253
|
+
logger.info(
|
|
1254
|
+
`[audition-engine] Outward intent from ${entityId}'s facet → ${intent.target} ` +
|
|
1255
|
+
`(handing to master; the facet does not open that channel itself)`
|
|
1256
|
+
)
|
|
1257
|
+
handoff({
|
|
1258
|
+
kind: 'undertaking',
|
|
1259
|
+
target: intent.target,
|
|
1260
|
+
reasoning: intent.reasoning ?? '',
|
|
1261
|
+
...( intent.gist ? { gist: intent.gist } : {} ),
|
|
1262
|
+
})
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ── Master escalation signal ──────────────────────────────
|
|
1266
|
+
// Published when the facet emits an 'escalate' action type.
|
|
1267
|
+
if( d.requiresMasterAttention )
|
|
1268
|
+
handoff({ kind: 'escalation', reasoning: decision.reasoning })
|
|
911
1269
|
}
|
|
912
1270
|
finally {
|
|
913
1271
|
// Release the entity's turn queue so the next message can be processed.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* domains (see §6 cross-modal note).
|
|
21
21
|
*
|
|
22
22
|
* - `AuditionEngine` extends this and overrides `publishes()`/`snapshot()` for its
|
|
23
|
-
* extra `
|
|
23
|
+
* extra `executive.facet.handoff` schema and session snapshot.
|
|
24
24
|
* - The four shell engines extend `ShellSenseEngine` (below) and are ~6 lines each.
|
|
25
25
|
*/
|
|
26
26
|
|