emocentric 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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/dist/actions/action.d.ts +70 -0
  4. package/dist/actions/action.js +47 -0
  5. package/dist/actions/index.d.ts +2 -0
  6. package/dist/actions/index.js +2 -0
  7. package/dist/actions/registry.d.ts +12 -0
  8. package/dist/actions/registry.js +26 -0
  9. package/dist/agent.d.ts +141 -0
  10. package/dist/agent.js +246 -0
  11. package/dist/blueprint/blueprint.d.ts +60 -0
  12. package/dist/blueprint/blueprint.js +20 -0
  13. package/dist/blueprint/index.d.ts +2 -0
  14. package/dist/blueprint/index.js +2 -0
  15. package/dist/blueprint/scope.d.ts +26 -0
  16. package/dist/blueprint/scope.js +21 -0
  17. package/dist/emotion/definitions.d.ts +34 -0
  18. package/dist/emotion/definitions.js +43 -0
  19. package/dist/emotion/index.d.ts +3 -0
  20. package/dist/emotion/index.js +3 -0
  21. package/dist/emotion/persona.d.ts +35 -0
  22. package/dist/emotion/persona.js +22 -0
  23. package/dist/emotion/state.d.ts +52 -0
  24. package/dist/emotion/state.js +35 -0
  25. package/dist/events/context-event.d.ts +49 -0
  26. package/dist/events/context-event.js +43 -0
  27. package/dist/events/guards.d.ts +14 -0
  28. package/dist/events/guards.js +72 -0
  29. package/dist/events/index.d.ts +8 -0
  30. package/dist/events/index.js +5 -0
  31. package/dist/events/media.d.ts +23 -0
  32. package/dist/events/media.js +16 -0
  33. package/dist/events/merge.d.ts +24 -0
  34. package/dist/events/merge.js +61 -0
  35. package/dist/events/payload.d.ts +54 -0
  36. package/dist/events/payload.js +8 -0
  37. package/dist/gateway/client.d.ts +28 -0
  38. package/dist/gateway/client.js +79 -0
  39. package/dist/gateway/index.d.ts +1 -0
  40. package/dist/gateway/index.js +1 -0
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.js +13 -0
  43. package/dist/llm/index.d.ts +1 -0
  44. package/dist/llm/index.js +1 -0
  45. package/dist/llm/types.d.ts +40 -0
  46. package/dist/llm/types.js +1 -0
  47. package/dist/memory/fact.d.ts +54 -0
  48. package/dist/memory/fact.js +1 -0
  49. package/dist/memory/index.d.ts +4 -0
  50. package/dist/memory/index.js +2 -0
  51. package/dist/memory/openai-embedder.d.ts +50 -0
  52. package/dist/memory/openai-embedder.js +124 -0
  53. package/dist/memory/scoring.d.ts +24 -0
  54. package/dist/memory/scoring.js +20 -0
  55. package/dist/memory/store.d.ts +79 -0
  56. package/dist/memory/store.js +1 -0
  57. package/dist/openrouter/client.d.ts +27 -0
  58. package/dist/openrouter/client.js +118 -0
  59. package/dist/openrouter/index.d.ts +1 -0
  60. package/dist/openrouter/index.js +1 -0
  61. package/dist/pipeline/actor.d.ts +31 -0
  62. package/dist/pipeline/actor.js +11 -0
  63. package/dist/pipeline/appraiser.d.ts +55 -0
  64. package/dist/pipeline/appraiser.js +132 -0
  65. package/dist/pipeline/consolidator.d.ts +62 -0
  66. package/dist/pipeline/consolidator.js +98 -0
  67. package/dist/pipeline/inbox.d.ts +60 -0
  68. package/dist/pipeline/inbox.js +107 -0
  69. package/dist/pipeline/index.d.ts +11 -0
  70. package/dist/pipeline/index.js +11 -0
  71. package/dist/pipeline/interpretation.d.ts +47 -0
  72. package/dist/pipeline/interpretation.js +24 -0
  73. package/dist/pipeline/llm-interpreter.d.ts +28 -0
  74. package/dist/pipeline/llm-interpreter.js +173 -0
  75. package/dist/pipeline/loop.d.ts +58 -0
  76. package/dist/pipeline/loop.js +73 -0
  77. package/dist/pipeline/processor.d.ts +63 -0
  78. package/dist/pipeline/processor.js +144 -0
  79. package/dist/pipeline/recaller.d.ts +34 -0
  80. package/dist/pipeline/recaller.js +38 -0
  81. package/dist/pipeline/time-appraiser.d.ts +85 -0
  82. package/dist/pipeline/time-appraiser.js +189 -0
  83. package/dist/pipeline/writer.d.ts +113 -0
  84. package/dist/pipeline/writer.js +210 -0
  85. package/dist/postgres/create-instance.d.ts +107 -0
  86. package/dist/postgres/create-instance.js +182 -0
  87. package/dist/postgres/emotional-state.d.ts +42 -0
  88. package/dist/postgres/emotional-state.js +134 -0
  89. package/dist/postgres/index.d.ts +5 -0
  90. package/dist/postgres/index.js +5 -0
  91. package/dist/postgres/instances.d.ts +32 -0
  92. package/dist/postgres/instances.js +77 -0
  93. package/dist/postgres/schema.d.ts +31 -0
  94. package/dist/postgres/schema.js +98 -0
  95. package/dist/postgres/short-term.d.ts +32 -0
  96. package/dist/postgres/short-term.js +95 -0
  97. package/dist/postgres/store.d.ts +85 -0
  98. package/dist/postgres/store.js +347 -0
  99. package/dist/short-term/index.d.ts +1 -0
  100. package/dist/short-term/index.js +1 -0
  101. package/dist/short-term/short-term.d.ts +66 -0
  102. package/dist/short-term/short-term.js +72 -0
  103. package/package.json +50 -0
@@ -0,0 +1,173 @@
1
+ import { renderEngagements } from "../short-term/short-term.js";
2
+ const INTERPRETATION_SCHEMA = {
3
+ name: "interpretation",
4
+ schema: {
5
+ type: "object",
6
+ properties: {
7
+ description: { type: "string" },
8
+ fullyInterpreted: { type: "boolean" },
9
+ gaps: { type: "array", items: { type: "string" } },
10
+ },
11
+ required: ["description", "fullyInterpreted", "gaps"],
12
+ additionalProperties: false,
13
+ },
14
+ };
15
+ /**
16
+ * The default multimodal interpreter: one LLM call that sees the event's
17
+ * text/images directly (audio/video via their transcripts or captions) plus
18
+ * the memories recalled so far, and produces a grounded description.
19
+ */
20
+ export class LLMInterpreter {
21
+ options;
22
+ constructor(options) {
23
+ this.options = options;
24
+ }
25
+ async interpret(event, memories, recent = []) {
26
+ const recentText = recentSection(recent, event.occurredAt);
27
+ const request = {
28
+ system: this.systemPrompt(),
29
+ messages: [
30
+ {
31
+ role: "user",
32
+ content: [
33
+ ...(recentText ? [{ type: "text", text: recentText }] : []),
34
+ { type: "text", text: eventHeader(event) },
35
+ ...payloadToContent(event.payload),
36
+ { type: "text", text: memoriesSection(memories) },
37
+ ],
38
+ },
39
+ ],
40
+ jsonSchema: {
41
+ name: INTERPRETATION_SCHEMA.name,
42
+ schema: INTERPRETATION_SCHEMA.schema,
43
+ },
44
+ ...(this.options.maxTokens !== undefined && {
45
+ maxTokens: this.options.maxTokens,
46
+ }),
47
+ };
48
+ const response = await this.options.llm.complete(request);
49
+ return parseInterpretation(response.text, this.options.maxGaps ?? 3);
50
+ }
51
+ systemPrompt() {
52
+ const persona = this.options.personaHint
53
+ ? ` You perceive on behalf of this agent: ${this.options.personaHint}.`
54
+ : "";
55
+ return ("You are the perceptual system of an emotionally-driven agent." +
56
+ persona +
57
+ " Given an incoming event and the agent's relevant memories, state plainly and concisely what just happened, as the agent would understand it.\n" +
58
+ "- Be brief and to the point — usually one or two sentences. Keep the key content: who is involved and what they did or said, including any name or self-introduction, plan, or concrete fact the event reveals. Cut pleasantries and restating — but someone giving their name or sharing new information is key content, never filler.\n" +
59
+ "- If RECENT EXCHANGES are shown, use them to understand replies and follow-ups — what a 'yeah', a 'that', or a short answer refers to. They are the immediate conversation (with how long ago each happened); the memories below are durable facts.\n" +
60
+ "- Use the memories ONLY to resolve references — to identify who or what the event points to (e.g. 'my friend' → Jason when a memory says so). Weave in an identity only when a memory actually grounds it.\n" +
61
+ "- If the actor reveals their own name or identity (e.g. the sender says \"it's Roy\"), state it explicitly as the actor introducing themselves (e.g. 'the user introduces himself as Roy and …') — this is important new information the system may not have yet, not an already-known name.\n" +
62
+ "- Never invent or infer: do not add relationships, roles, backstory, or any detail not stated in the event or grounded in a memory (e.g. don't call someone a 'friend' or 'teammate' unless that is known). Unknowns stay unknown.\n" +
63
+ "- Describe, don't editorialize: no mood, tone, sentiment, or significance ('a warm message', 'a sense of camaraderie') — later stages do the feeling.\n" +
64
+ 'Respond with JSON only: {"description": string, "fullyInterpreted": boolean, "gaps": string[]}.\n' +
65
+ "fullyInterpreted is true only when every notable element of the event is explained by the event itself or a memory. " +
66
+ "gaps lists at most 3 short memory-search queries that would fill what's unexplained (empty when nothing is missing).");
67
+ }
68
+ }
69
+ function eventHeader(event) {
70
+ const actor = event.actor.displayName
71
+ ? `${event.actor.displayName} (id ${event.actor.id})`
72
+ : `id ${event.actor.id}`;
73
+ const channel = event.channelId ? `; channel: ${event.channelId}` : "";
74
+ return `EVENT — platform: ${event.platform}; actor: ${actor}; time: ${event.occurredAt}${channel}. Payload follows:`;
75
+ }
76
+ function payloadToContent(payload) {
77
+ if (payload.kind === "composite") {
78
+ return payload.parts.flatMap((part, i) => [
79
+ { type: "text", text: `— part ${i + 1} of ${payload.parts.length} —` },
80
+ ...simplePayloadToContent(part),
81
+ ]);
82
+ }
83
+ return simplePayloadToContent(payload);
84
+ }
85
+ function simplePayloadToContent(payload) {
86
+ switch (payload.kind) {
87
+ case "text":
88
+ return [{ type: "text", text: `[text message]\n${payload.text}` }];
89
+ case "image":
90
+ return [
91
+ ...(payload.caption !== undefined
92
+ ? [{ type: "text", text: `[image, caption: ${payload.caption}]` }]
93
+ : [{ type: "text", text: "[image]" }]),
94
+ { type: "image", source: payload.source },
95
+ ];
96
+ case "audio":
97
+ return [
98
+ {
99
+ type: "text",
100
+ text: payload.transcript
101
+ ? `[audio${durationNote(payload.durationMs)}, transcript follows]\n${payload.transcript}`
102
+ : `[audio${durationNote(payload.durationMs)} — no transcript available; its spoken content is unknown]`,
103
+ },
104
+ ];
105
+ case "video":
106
+ return [
107
+ {
108
+ type: "text",
109
+ text: payload.transcript
110
+ ? `[video${durationNote(payload.durationMs)}, transcript follows]\n${payload.transcript}`
111
+ : `[video${durationNote(payload.durationMs)} — no transcript available; its content is unknown]`,
112
+ },
113
+ ];
114
+ case "action":
115
+ return [
116
+ {
117
+ type: "text",
118
+ text: `[platform action] name: ${payload.name}` +
119
+ (payload.description ? `; description: ${payload.description}` : "") +
120
+ (payload.data ? `; data: ${JSON.stringify(payload.data)}` : ""),
121
+ },
122
+ ];
123
+ }
124
+ }
125
+ function durationNote(durationMs) {
126
+ return durationMs !== undefined ? `, ~${Math.round(durationMs / 1000)}s` : "";
127
+ }
128
+ function memoriesSection(memories) {
129
+ if (memories.length === 0) {
130
+ return "MEMORIES: none retrieved yet (first pass).";
131
+ }
132
+ const lines = memories.map((fact, i) => `${i + 1}. [${fact.kind}] ${fact.content}`);
133
+ return `MEMORIES (${memories.length}):\n${lines.join("\n")}`;
134
+ }
135
+ /** The short-term window as a leading context block; "" when empty so the
136
+ * neutral first pass carries no recent context at all. */
137
+ function recentSection(recent, now) {
138
+ const rendered = renderEngagements(recent, now);
139
+ if (rendered === "")
140
+ return "";
141
+ return `RECENT EXCHANGES (the immediate back-and-forth, most recent last; [how long ago] in brackets):\n${rendered}`;
142
+ }
143
+ /** Tolerant parse: providers without strict schema support may wrap the
144
+ * JSON in prose or fences. Worst case, the raw text becomes the description. */
145
+ export function parseInterpretation(text, maxGaps) {
146
+ const candidate = extractJsonObject(text);
147
+ if (candidate) {
148
+ try {
149
+ const parsed = JSON.parse(candidate);
150
+ if (typeof parsed.description === "string") {
151
+ const gaps = Array.isArray(parsed.gaps)
152
+ ? parsed.gaps
153
+ .filter((g) => typeof g === "string" && g.length > 0)
154
+ .slice(0, maxGaps)
155
+ : [];
156
+ return {
157
+ description: parsed.description,
158
+ fullyInterpreted: parsed.fullyInterpreted === true,
159
+ gaps,
160
+ };
161
+ }
162
+ }
163
+ catch {
164
+ // fall through to raw-text fallback
165
+ }
166
+ }
167
+ return { description: text.trim(), fullyInterpreted: false, gaps: [] };
168
+ }
169
+ function extractJsonObject(text) {
170
+ const start = text.indexOf("{");
171
+ const end = text.lastIndexOf("}");
172
+ return start !== -1 && end > start ? text.slice(start, end + 1) : null;
173
+ }
@@ -0,0 +1,58 @@
1
+ import type { ContextEvent } from "../events/context-event.js";
2
+ import type { ScoredFact } from "../memory/store.js";
3
+ import type { Engagement } from "../short-term/short-term.js";
4
+ import type { Interpretation, Interpreter } from "./interpretation.js";
5
+ import type { Recaller } from "./recaller.js";
6
+ export type StopReason =
7
+ /** First recall found nothing — a second look would be identical. */
8
+ "no-memories"
9
+ /** A recall round surfaced no memory ids we hadn't already seen — the
10
+ * next description would be built from the same inputs. */
11
+ | "stabilized"
12
+ /** The interpreter reported fullyInterpreted with no remaining gaps. */
13
+ | "fully-interpreted"
14
+ /** Hit the iteration cap (cost/latency seatbelt). */
15
+ | "max-iterations";
16
+ export interface InterpretRecallResult {
17
+ /** The final, memory-grounded description — input to appraiser/processor. */
18
+ description: string;
19
+ /** Full final interpretation (description + self-report + gaps). */
20
+ interpretation: Interpretation;
21
+ /** Memories relevant to the final description, ranked. */
22
+ memories: ScoredFact[];
23
+ /** Number of interpreter (LLM) calls made. */
24
+ iterations: number;
25
+ stopReason: StopReason;
26
+ }
27
+ export interface InterpretRecallLoopOptions {
28
+ interpreter: Interpreter;
29
+ recaller: Recaller;
30
+ /** Max interpreter calls per event. Default 3. */
31
+ maxIterations?: number;
32
+ }
33
+ /**
34
+ * The interpret → recall → reinterpret loop:
35
+ *
36
+ * 1. interpret the raw event with no memories
37
+ * 2. recall memories matching the description (and its gaps)
38
+ * 3. reinterpret with those memories in hand
39
+ * 4. repeat until no new memories turn up, the interpreter declares
40
+ * itself done, or the iteration cap is reached
41
+ *
42
+ * Each iteration costs one LLM call; recalls are database-only.
43
+ */
44
+ export declare class InterpretRecallLoop {
45
+ private readonly interpreter;
46
+ private readonly recaller;
47
+ private readonly maxIterations;
48
+ constructor(options: InterpretRecallLoopOptions);
49
+ /**
50
+ * @param recent The agent's short-term window. The FIRST interpret pass is
51
+ * always neutral — no memories AND no short-term context — for an unbiased
52
+ * read of the raw event. Recalled memories and `recent` join from the
53
+ * second pass. When `recent` is non-empty, the loop guarantees at least one
54
+ * context pass, so a simple first read can't skip the immediate thread.
55
+ */
56
+ run(event: ContextEvent, recent?: Engagement[]): Promise<InterpretRecallResult>;
57
+ private result;
58
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The interpret → recall → reinterpret loop:
3
+ *
4
+ * 1. interpret the raw event with no memories
5
+ * 2. recall memories matching the description (and its gaps)
6
+ * 3. reinterpret with those memories in hand
7
+ * 4. repeat until no new memories turn up, the interpreter declares
8
+ * itself done, or the iteration cap is reached
9
+ *
10
+ * Each iteration costs one LLM call; recalls are database-only.
11
+ */
12
+ export class InterpretRecallLoop {
13
+ interpreter;
14
+ recaller;
15
+ maxIterations;
16
+ constructor(options) {
17
+ this.interpreter = options.interpreter;
18
+ this.recaller = options.recaller;
19
+ this.maxIterations = Math.max(1, options.maxIterations ?? 3);
20
+ }
21
+ /**
22
+ * @param recent The agent's short-term window. The FIRST interpret pass is
23
+ * always neutral — no memories AND no short-term context — for an unbiased
24
+ * read of the raw event. Recalled memories and `recent` join from the
25
+ * second pass. When `recent` is non-empty, the loop guarantees at least one
26
+ * context pass, so a simple first read can't skip the immediate thread.
27
+ */
28
+ async run(event, recent = []) {
29
+ const seenIds = new Set();
30
+ let interpretation = await this.interpreter.interpret(event, [], []);
31
+ let iterations = 1;
32
+ let memories = [];
33
+ let usedRecent = false;
34
+ for (;;) {
35
+ const recalled = await this.recaller.recall(interpretation, event);
36
+ const hasNew = recalled.some((r) => !seenIds.has(r.fact.id));
37
+ // The neutral read is final only when there's nothing new to fold in:
38
+ // no fresh memories AND the short-term window has already been applied
39
+ // (or is empty), OR the neutral read already fully explained the event.
40
+ // Gating the forced STM pass on `!fullyInterpreted` means a self-contained
41
+ // event stops at one interpret call even when a window exists, while an
42
+ // ambiguous/relational one ("yeah", "my sister") still gets the context
43
+ // pass that resolves it.
44
+ const needContextPass = recent.length > 0 && !usedRecent && !interpretation.fullyInterpreted;
45
+ if (!hasNew && !needContextPass) {
46
+ return this.result(interpretation, memories.length > 0 ? memories : recalled, iterations, iterations === 1 ? "no-memories" : "stabilized");
47
+ }
48
+ for (const r of recalled)
49
+ seenIds.add(r.fact.id);
50
+ if (recalled.length > 0)
51
+ memories = recalled;
52
+ if (iterations >= this.maxIterations) {
53
+ return this.result(interpretation, memories, iterations, "max-iterations");
54
+ }
55
+ // Reinterpret WITH the short-term window and any recalled memories.
56
+ interpretation = await this.interpreter.interpret(event, memories.map((m) => m.fact), recent);
57
+ usedRecent = usedRecent || recent.length > 0;
58
+ iterations++;
59
+ if (interpretation.fullyInterpreted && interpretation.gaps.length === 0) {
60
+ return this.result(interpretation, memories, iterations, "fully-interpreted");
61
+ }
62
+ }
63
+ }
64
+ result(interpretation, memories, iterations, stopReason) {
65
+ return {
66
+ description: interpretation.description,
67
+ interpretation,
68
+ memories,
69
+ iterations,
70
+ stopReason,
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,63 @@
1
+ import type { ContextEvent } from "../events/context-event.js";
2
+ import type { Fact } from "../memory/fact.js";
3
+ import { type Engagement } from "../short-term/short-term.js";
4
+ import type { EmotionalState } from "../emotion/state.js";
5
+ import type { Persona } from "../emotion/persona.js";
6
+ import type { LLMClient } from "../llm/types.js";
7
+ import { ActionPlan } from "../actions/action.js";
8
+ import type { ActionRegistry } from "../actions/registry.js";
9
+ export interface ProcessInput {
10
+ event: ContextEvent;
11
+ /** The interpret/recall loop's final, memory-grounded description. */
12
+ description: string;
13
+ memories: Fact[];
14
+ state: EmotionalState;
15
+ registry: ActionRegistry;
16
+ persona?: Persona;
17
+ /** The agent's short-term window, for staying coherent across the recent
18
+ * back-and-forth (not repeating itself, following up naturally). */
19
+ recent?: Engagement[];
20
+ }
21
+ export interface ProcessResult {
22
+ /** The chosen actions. The SDK never executes — call `plan.execute()`
23
+ * yourself (the agent decides, you act). */
24
+ plan: ActionPlan;
25
+ }
26
+ /**
27
+ * Decides what the agent does about an event. One method — swap in a
28
+ * rules-based or learned processor and the registry/callers don't change.
29
+ */
30
+ export interface Processor {
31
+ process(input: ProcessInput): Promise<ProcessResult>;
32
+ }
33
+ export interface LLMProcessorOptions {
34
+ llm: LLMClient;
35
+ /** Cap on accepted actions per event. Default 5. */
36
+ maxActions?: number;
37
+ maxTokens?: number;
38
+ }
39
+ interface Selection {
40
+ name: string;
41
+ params: Record<string, unknown>;
42
+ reason?: string;
43
+ }
44
+ /**
45
+ * Default processor: one LLM call that sees the persona, every emotion's
46
+ * current value, the event description, the memories, and the action menu
47
+ * (with slots and param schemas) — and picks what to do, in character.
48
+ * Choosing nothing is valid. Slot conflicts are resolved first-pick-wins.
49
+ */
50
+ export declare class LLMProcessor implements Processor {
51
+ private readonly llm;
52
+ private readonly maxActions;
53
+ private readonly maxTokens;
54
+ constructor(options: LLMProcessorOptions);
55
+ process(input: ProcessInput): Promise<ProcessResult>;
56
+ private systemPrompt;
57
+ private userPrompt;
58
+ }
59
+ /** Tolerant parse of processor output: extracts JSON from prose, defaults
60
+ * missing/invalid params to {}, drops malformed entries. Unparseable
61
+ * output yields an empty plan (the agent does nothing). */
62
+ export declare function parseSelections(text: string): Selection[];
63
+ export {};
@@ -0,0 +1,144 @@
1
+ import { renderEngagements } from "../short-term/short-term.js";
2
+ import { ActionPlan, } from "../actions/action.js";
3
+ /**
4
+ * Default processor: one LLM call that sees the persona, every emotion's
5
+ * current value, the event description, the memories, and the action menu
6
+ * (with slots and param schemas) — and picks what to do, in character.
7
+ * Choosing nothing is valid. Slot conflicts are resolved first-pick-wins.
8
+ */
9
+ export class LLMProcessor {
10
+ llm;
11
+ maxActions;
12
+ maxTokens;
13
+ constructor(options) {
14
+ this.llm = options.llm;
15
+ this.maxActions = options.maxActions ?? 5;
16
+ this.maxTokens = options.maxTokens;
17
+ }
18
+ async process(input) {
19
+ const emotions = await input.state.snapshot();
20
+ const response = await this.llm.complete({
21
+ system: this.systemPrompt(input.persona),
22
+ messages: [
23
+ {
24
+ role: "user",
25
+ content: [{ type: "text", text: this.userPrompt(input, emotions) }],
26
+ },
27
+ ],
28
+ ...(this.maxTokens !== undefined && { maxTokens: this.maxTokens }),
29
+ });
30
+ const selections = parseSelections(response.text);
31
+ const accepted = [];
32
+ const droppedConflicts = [];
33
+ const unknownActions = [];
34
+ const takenSlots = new Set();
35
+ for (const selection of selections) {
36
+ const action = input.registry.get(selection.name);
37
+ if (!action) {
38
+ unknownActions.push(selection.name);
39
+ continue;
40
+ }
41
+ const planned = {
42
+ action,
43
+ params: selection.params,
44
+ ...(selection.reason !== undefined && { reason: selection.reason }),
45
+ };
46
+ if (action.slot !== undefined && takenSlots.has(action.slot)) {
47
+ droppedConflicts.push(planned);
48
+ continue;
49
+ }
50
+ if (accepted.length >= this.maxActions) {
51
+ droppedConflicts.push(planned);
52
+ continue;
53
+ }
54
+ if (action.slot !== undefined)
55
+ takenSlots.add(action.slot);
56
+ accepted.push(planned);
57
+ }
58
+ const context = {
59
+ event: input.event,
60
+ description: input.description,
61
+ memories: input.memories,
62
+ emotions,
63
+ ...(input.persona !== undefined && { persona: input.persona }),
64
+ };
65
+ const plan = new ActionPlan(accepted, droppedConflicts, unknownActions, context);
66
+ return { plan };
67
+ }
68
+ systemPrompt(persona) {
69
+ const identity = persona
70
+ ? `You are the action-selection system of ${persona.name}: ${persona.summary}.`
71
+ : "You are the action-selection system of an agent.";
72
+ return (identity +
73
+ " Given what just happened, what the agent remembers, and how the agent feels RIGHT NOW, choose what the agent does.\n" +
74
+ "- Let the current emotions drive the choice: high anger acts differently than high warmth. Stay in character.\n" +
75
+ "- If RECENT EXCHANGES are shown, stay coherent with them — don't repeat a message or question you just sent; follow up naturally.\n" +
76
+ "- Actions sharing a slot are mutually exclusive — pick at most one per slot.\n" +
77
+ "- Fill each action's params exactly per its schema (e.g. the actual words of a message, in the agent's voice).\n" +
78
+ "- Doing nothing is a valid choice: return an empty list when no action fits.\n" +
79
+ 'Respond with JSON only, no prose: {"actions": [{"name": string, "params": object, "reason": string}]} ' +
80
+ 'where each "params" is filled according to that action\'s params schema, ' +
81
+ 'e.g. {"actions": [{"name": "send_message", "params": {"text": "the actual words to say"}, "reason": "..."}]}.');
82
+ }
83
+ userPrompt(input, emotions) {
84
+ const definitions = input.state.definitions();
85
+ const emotionLines = definitions.map((def) => `- ${def.name} (current ${(emotions[def.name] ?? def.baseline).toFixed(2)}): ${def.description}`);
86
+ const actionLines = input.registry.list().map((action) => {
87
+ const slot = action.slot !== undefined
88
+ ? ` (slot: ${action.slot} — at most one action from this slot)`
89
+ : "";
90
+ const params = action.paramsSchema
91
+ ? ` params schema: ${JSON.stringify(action.paramsSchema)}`
92
+ : " no params (use {})";
93
+ return `- ${action.name}${slot}: ${action.description}.${params}`;
94
+ });
95
+ const memoryLines = input.memories.length === 0
96
+ ? "none relevant"
97
+ : input.memories
98
+ .map((fact, i) => `${i + 1}. [${fact.kind}] ${fact.content}`)
99
+ .join("\n");
100
+ const actor = input.event.actor.displayName ?? input.event.actor.id;
101
+ const recentText = renderEngagements(input.recent ?? [], input.event.occurredAt);
102
+ return (`CURRENT EMOTIONS (value 0..1):\n${emotionLines.join("\n")}\n\n` +
103
+ `WHAT JUST HAPPENED (platform: ${input.event.platform}; actor: ${actor}; time: ${input.event.occurredAt}):\n` +
104
+ `${input.description}\n\n` +
105
+ (recentText
106
+ ? `RECENT EXCHANGES (most recent last; [how long ago] in brackets):\n${recentText}\n\n`
107
+ : "") +
108
+ `RELEVANT MEMORIES:\n${memoryLines}\n\n` +
109
+ `AVAILABLE ACTIONS:\n${actionLines.join("\n")}`);
110
+ }
111
+ }
112
+ /** Tolerant parse of processor output: extracts JSON from prose, defaults
113
+ * missing/invalid params to {}, drops malformed entries. Unparseable
114
+ * output yields an empty plan (the agent does nothing). */
115
+ export function parseSelections(text) {
116
+ const start = text.indexOf("{");
117
+ const end = text.lastIndexOf("}");
118
+ if (start === -1 || end <= start)
119
+ return [];
120
+ try {
121
+ const parsed = JSON.parse(text.slice(start, end + 1));
122
+ if (!Array.isArray(parsed.actions))
123
+ return [];
124
+ const selections = [];
125
+ for (const item of parsed.actions) {
126
+ if (typeof item !== "object" || item === null)
127
+ continue;
128
+ const { name, params, reason } = item;
129
+ if (typeof name !== "string" || name.trim() === "")
130
+ continue;
131
+ selections.push({
132
+ name: name.trim(),
133
+ params: typeof params === "object" && params !== null && !Array.isArray(params)
134
+ ? params
135
+ : {},
136
+ ...(typeof reason === "string" && reason.length > 0 && { reason }),
137
+ });
138
+ }
139
+ return selections;
140
+ }
141
+ catch {
142
+ return [];
143
+ }
144
+ }
@@ -0,0 +1,34 @@
1
+ import type { ContextEvent } from "../events/context-event.js";
2
+ import type { MemoryStore, ScoredFact } from "../memory/store.js";
3
+ import type { Interpretation } from "./interpretation.js";
4
+ /**
5
+ * Finds memories relevant to an interpretation. No LLM involved — this is
6
+ * a pure memory lookup, so it's cheap to run every loop iteration.
7
+ * Swappable like everything else (e.g. an LLM-powered query planner).
8
+ */
9
+ export interface Recaller {
10
+ recall(interpretation: Interpretation, event: ContextEvent): Promise<ScoredFact[]>;
11
+ }
12
+ export interface MemoryRecallerOptions {
13
+ store: MemoryStore;
14
+ /** Per-query fetch cap. Default: unbounded — return every fact above the
15
+ * store's relevance floor (no fixed top-K). */
16
+ limitPerQuery?: number;
17
+ /** Cap on the merged result set. Default: unbounded — the floor bounds it. */
18
+ totalLimit?: number;
19
+ /** Forwarded to MemoryStore.fetch. */
20
+ minScore?: number;
21
+ }
22
+ /**
23
+ * Default recaller: searches memory once with the full description and once
24
+ * per gap the interpreter flagged ("who is the woman in the photo"), then
25
+ * merges by fact id keeping each fact's best score.
26
+ */
27
+ export declare class MemoryRecaller implements Recaller {
28
+ private readonly store;
29
+ private readonly limitPerQuery;
30
+ private readonly totalLimit;
31
+ private readonly minScore;
32
+ constructor(options: MemoryRecallerOptions);
33
+ recall(interpretation: Interpretation, _event: ContextEvent): Promise<ScoredFact[]>;
34
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Default recaller: searches memory once with the full description and once
3
+ * per gap the interpreter flagged ("who is the woman in the photo"), then
4
+ * merges by fact id keeping each fact's best score.
5
+ */
6
+ export class MemoryRecaller {
7
+ store;
8
+ limitPerQuery;
9
+ totalLimit;
10
+ minScore;
11
+ constructor(options) {
12
+ this.store = options.store;
13
+ this.limitPerQuery = options.limitPerQuery;
14
+ this.totalLimit = options.totalLimit;
15
+ this.minScore = options.minScore;
16
+ }
17
+ async recall(interpretation, _event) {
18
+ const queries = [interpretation.description, ...interpretation.gaps].filter((q) => q.trim().length > 0);
19
+ const merged = new Map();
20
+ for (const text of queries) {
21
+ const results = await this.store.fetch({
22
+ text,
23
+ ...(this.limitPerQuery !== undefined && { limit: this.limitPerQuery }),
24
+ ...(this.minScore !== undefined && { minScore: this.minScore }),
25
+ });
26
+ for (const result of results) {
27
+ const existing = merged.get(result.fact.id);
28
+ if (!existing || result.score > existing.score) {
29
+ merged.set(result.fact.id, result);
30
+ }
31
+ }
32
+ }
33
+ const ranked = [...merged.values()].sort((a, b) => b.score - a.score);
34
+ return this.totalLimit !== undefined
35
+ ? ranked.slice(0, this.totalLimit)
36
+ : ranked;
37
+ }
38
+ }
@@ -0,0 +1,85 @@
1
+ import type { ContextEvent } from "../events/context-event.js";
2
+ import type { EmotionDelta, EmotionSnapshot } from "../emotion/definitions.js";
3
+ import type { Persona } from "../emotion/persona.js";
4
+ import { type EmotionalState } from "../emotion/state.js";
5
+ import type { LLMClient } from "../llm/types.js";
6
+ import { type Engagement } from "../short-term/short-term.js";
7
+ export interface TimeAppraisalInput {
8
+ state: EmotionalState;
9
+ /** Advance feelings to this moment — usually the incoming event's
10
+ * occurredAt. Defaults to the current time. */
11
+ now?: string;
12
+ /** Unused by the math implementation; carried so an LLM-based time
13
+ * appraiser (which reasons about HOW downtime felt) slots in without
14
+ * changing call sites. */
15
+ event?: ContextEvent;
16
+ /** The short-term window (timestamped). Unused by the math implementation;
17
+ * an LLM time-appraiser reasons about how the gap felt given the recent
18
+ * thread (e.g. silence after an argument). */
19
+ recent?: Engagement[];
20
+ /** Default true. False = dry run: deltas + projected `after`, state untouched. */
21
+ apply?: boolean;
22
+ }
23
+ export interface TimeAppraisalResult {
24
+ /** Actual movements (after − before), with human-readable reasons. */
25
+ deltas: EmotionDelta[];
26
+ before: EmotionSnapshot;
27
+ after: EmotionSnapshot;
28
+ applied: boolean;
29
+ }
30
+ /**
31
+ * The "time passes" stage. Runs when an event arrives, BEFORE the
32
+ * interpret/recall loop: feelings advance to the present moment first, then
33
+ * the event itself is appraised. One method — swap the math for an LLM (or
34
+ * anything else) by implementing it.
35
+ */
36
+ export interface TimeAppraiser {
37
+ appraise(input: TimeAppraisalInput): Promise<TimeAppraisalResult>;
38
+ }
39
+ export interface DecayTimeAppraiserOptions {
40
+ /** Half-life for emotions that don't specify their own. Default 240 (4h). */
41
+ defaultHalfLifeMinutes?: number;
42
+ }
43
+ /**
44
+ * Mathematical time appraisal: every emotion drifts exponentially toward
45
+ * its baseline, each from its own last-touched timestamp, with per-emotion
46
+ * (or persona-temperament) half-life overrides. Deterministic, no LLM call.
47
+ * Decay is written via state.set() — exact targets, no sensitivity scaling.
48
+ */
49
+ export declare class DecayTimeAppraiser implements TimeAppraiser {
50
+ private readonly defaultHalfLifeMinutes;
51
+ constructor(options?: DecayTimeAppraiserOptions);
52
+ appraise(input: TimeAppraisalInput): Promise<TimeAppraisalResult>;
53
+ }
54
+ export interface LLMTimeAppraiserOptions {
55
+ llm: LLMClient;
56
+ /** Summary + dispositions go into the prompt so the drift is in character. */
57
+ persona?: Persona;
58
+ /** Skip the LLM (a no-op) when less than this much time has passed — rapid
59
+ * exchanges aren't "downtime" and decay over a couple minutes is negligible.
60
+ * Default 2. Set 0 to always call. */
61
+ minGapMinutes?: number;
62
+ maxTokens?: number;
63
+ }
64
+ /**
65
+ * LLM time appraisal: instead of pure exponential decay, one LLM call reasons
66
+ * about how the DOWNTIME felt — given how long it's been and the recent thread
67
+ * (short-term memory, timestamped). Feelings still generally fade toward
68
+ * baseline, but context can sustain or even grow them (anger that stays warm,
69
+ * missing someone over a long silence). Returns per-emotion deltas, applied
70
+ * like the event appraiser's.
71
+ *
72
+ * MUST run past the heartbeat's commit point (the Agent places it there): it
73
+ * makes an LLM call and is non-deterministic, so it must fire exactly once —
74
+ * a coalesced/cancelled heartbeat would otherwise re-run it.
75
+ */
76
+ export declare class LLMTimeAppraiser implements TimeAppraiser {
77
+ private readonly llm;
78
+ private readonly persona;
79
+ private readonly minGapMinutes;
80
+ private readonly maxTokens;
81
+ constructor(options: LLMTimeAppraiserOptions);
82
+ appraise(input: TimeAppraisalInput): Promise<TimeAppraisalResult>;
83
+ private systemPrompt;
84
+ private userPrompt;
85
+ }