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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/dist/actions/action.d.ts +70 -0
- package/dist/actions/action.js +47 -0
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +2 -0
- package/dist/actions/registry.d.ts +12 -0
- package/dist/actions/registry.js +26 -0
- package/dist/agent.d.ts +141 -0
- package/dist/agent.js +246 -0
- package/dist/blueprint/blueprint.d.ts +60 -0
- package/dist/blueprint/blueprint.js +20 -0
- package/dist/blueprint/index.d.ts +2 -0
- package/dist/blueprint/index.js +2 -0
- package/dist/blueprint/scope.d.ts +26 -0
- package/dist/blueprint/scope.js +21 -0
- package/dist/emotion/definitions.d.ts +34 -0
- package/dist/emotion/definitions.js +43 -0
- package/dist/emotion/index.d.ts +3 -0
- package/dist/emotion/index.js +3 -0
- package/dist/emotion/persona.d.ts +35 -0
- package/dist/emotion/persona.js +22 -0
- package/dist/emotion/state.d.ts +52 -0
- package/dist/emotion/state.js +35 -0
- package/dist/events/context-event.d.ts +49 -0
- package/dist/events/context-event.js +43 -0
- package/dist/events/guards.d.ts +14 -0
- package/dist/events/guards.js +72 -0
- package/dist/events/index.d.ts +8 -0
- package/dist/events/index.js +5 -0
- package/dist/events/media.d.ts +23 -0
- package/dist/events/media.js +16 -0
- package/dist/events/merge.d.ts +24 -0
- package/dist/events/merge.js +61 -0
- package/dist/events/payload.d.ts +54 -0
- package/dist/events/payload.js +8 -0
- package/dist/gateway/client.d.ts +28 -0
- package/dist/gateway/client.js +79 -0
- package/dist/gateway/index.d.ts +1 -0
- package/dist/gateway/index.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +13 -0
- package/dist/llm/index.d.ts +1 -0
- package/dist/llm/index.js +1 -0
- package/dist/llm/types.d.ts +40 -0
- package/dist/llm/types.js +1 -0
- package/dist/memory/fact.d.ts +54 -0
- package/dist/memory/fact.js +1 -0
- package/dist/memory/index.d.ts +4 -0
- package/dist/memory/index.js +2 -0
- package/dist/memory/openai-embedder.d.ts +50 -0
- package/dist/memory/openai-embedder.js +124 -0
- package/dist/memory/scoring.d.ts +24 -0
- package/dist/memory/scoring.js +20 -0
- package/dist/memory/store.d.ts +79 -0
- package/dist/memory/store.js +1 -0
- package/dist/openrouter/client.d.ts +27 -0
- package/dist/openrouter/client.js +118 -0
- package/dist/openrouter/index.d.ts +1 -0
- package/dist/openrouter/index.js +1 -0
- package/dist/pipeline/actor.d.ts +31 -0
- package/dist/pipeline/actor.js +11 -0
- package/dist/pipeline/appraiser.d.ts +55 -0
- package/dist/pipeline/appraiser.js +132 -0
- package/dist/pipeline/consolidator.d.ts +62 -0
- package/dist/pipeline/consolidator.js +98 -0
- package/dist/pipeline/inbox.d.ts +60 -0
- package/dist/pipeline/inbox.js +107 -0
- package/dist/pipeline/index.d.ts +11 -0
- package/dist/pipeline/index.js +11 -0
- package/dist/pipeline/interpretation.d.ts +47 -0
- package/dist/pipeline/interpretation.js +24 -0
- package/dist/pipeline/llm-interpreter.d.ts +28 -0
- package/dist/pipeline/llm-interpreter.js +173 -0
- package/dist/pipeline/loop.d.ts +58 -0
- package/dist/pipeline/loop.js +73 -0
- package/dist/pipeline/processor.d.ts +63 -0
- package/dist/pipeline/processor.js +144 -0
- package/dist/pipeline/recaller.d.ts +34 -0
- package/dist/pipeline/recaller.js +38 -0
- package/dist/pipeline/time-appraiser.d.ts +85 -0
- package/dist/pipeline/time-appraiser.js +189 -0
- package/dist/pipeline/writer.d.ts +113 -0
- package/dist/pipeline/writer.js +210 -0
- package/dist/postgres/create-instance.d.ts +107 -0
- package/dist/postgres/create-instance.js +182 -0
- package/dist/postgres/emotional-state.d.ts +42 -0
- package/dist/postgres/emotional-state.js +134 -0
- package/dist/postgres/index.d.ts +5 -0
- package/dist/postgres/index.js +5 -0
- package/dist/postgres/instances.d.ts +32 -0
- package/dist/postgres/instances.js +77 -0
- package/dist/postgres/schema.d.ts +31 -0
- package/dist/postgres/schema.js +98 -0
- package/dist/postgres/short-term.d.ts +32 -0
- package/dist/postgres/short-term.js +95 -0
- package/dist/postgres/store.d.ts +85 -0
- package/dist/postgres/store.js +347 -0
- package/dist/short-term/index.d.ts +1 -0
- package/dist/short-term/index.js +1 -0
- package/dist/short-term/short-term.d.ts +66 -0
- package/dist/short-term/short-term.js +72 -0
- 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
|
+
}
|