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,189 @@
|
|
|
1
|
+
import { decayValue, projectDeltas, } from "../emotion/state.js";
|
|
2
|
+
import { relativeTime, renderEngagements, } from "../short-term/short-term.js";
|
|
3
|
+
import { parseDeltas } from "./appraiser.js";
|
|
4
|
+
/** Moves smaller than this are skipped (no DB churn for noise). */
|
|
5
|
+
const MIN_MOVE = 0.001;
|
|
6
|
+
/**
|
|
7
|
+
* Mathematical time appraisal: every emotion drifts exponentially toward
|
|
8
|
+
* its baseline, each from its own last-touched timestamp, with per-emotion
|
|
9
|
+
* (or persona-temperament) half-life overrides. Deterministic, no LLM call.
|
|
10
|
+
* Decay is written via state.set() — exact targets, no sensitivity scaling.
|
|
11
|
+
*/
|
|
12
|
+
export class DecayTimeAppraiser {
|
|
13
|
+
defaultHalfLifeMinutes;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.defaultHalfLifeMinutes = options.defaultHalfLifeMinutes ?? 240;
|
|
16
|
+
}
|
|
17
|
+
async appraise(input) {
|
|
18
|
+
const apply = input.apply ?? true;
|
|
19
|
+
const now = input.now !== undefined ? Date.parse(input.now) : Date.now();
|
|
20
|
+
if (Number.isNaN(now)) {
|
|
21
|
+
throw new TypeError(`TimeAppraisalInput.now is not a date: ${input.now}`);
|
|
22
|
+
}
|
|
23
|
+
const definitions = new Map(input.state.definitions().map((d) => [d.name, d]));
|
|
24
|
+
const entries = await input.state.entries();
|
|
25
|
+
const before = {};
|
|
26
|
+
const targets = {};
|
|
27
|
+
const deltas = [];
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const def = definitions.get(entry.name);
|
|
30
|
+
if (!def)
|
|
31
|
+
continue;
|
|
32
|
+
before[entry.name] = entry.value;
|
|
33
|
+
const elapsedMs = Math.max(0, now - Date.parse(entry.updatedAt));
|
|
34
|
+
const halfLife = def.halfLifeMinutes ?? this.defaultHalfLifeMinutes;
|
|
35
|
+
const target = decayValue(entry.value, def.baseline, elapsedMs, halfLife);
|
|
36
|
+
if (Math.abs(target - entry.value) >= MIN_MOVE) {
|
|
37
|
+
targets[entry.name] = target;
|
|
38
|
+
deltas.push({
|
|
39
|
+
emotion: entry.name,
|
|
40
|
+
delta: target - entry.value,
|
|
41
|
+
reason: `time passed (~${formatElapsed(elapsedMs)}): drifting toward baseline ${def.baseline}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
let after;
|
|
46
|
+
if (deltas.length === 0) {
|
|
47
|
+
after = { ...before };
|
|
48
|
+
}
|
|
49
|
+
else if (apply) {
|
|
50
|
+
// Stamp the decayed values AS OF the appraisal moment, not wall clock:
|
|
51
|
+
// each slider's last-touched time then reflects when its value is
|
|
52
|
+
// current, so the next event measures the true elapsed gap. (Only
|
|
53
|
+
// sliders that moved are written, and a slider moves only when the
|
|
54
|
+
// moment is later than its last touch — so this never rewinds a stamp.)
|
|
55
|
+
after = await input.state.set(targets, new Date(now).toISOString());
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
after = { ...before };
|
|
59
|
+
for (const [name, value] of Object.entries(targets)) {
|
|
60
|
+
if (typeof value === "number")
|
|
61
|
+
after[name] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { deltas, before, after, applied: apply };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function formatElapsed(elapsedMs) {
|
|
68
|
+
const minutes = elapsedMs / 60_000;
|
|
69
|
+
if (minutes < 90)
|
|
70
|
+
return `${Math.round(minutes)}m`;
|
|
71
|
+
const hours = minutes / 60;
|
|
72
|
+
if (hours < 48)
|
|
73
|
+
return `${hours.toFixed(1)}h`;
|
|
74
|
+
return `${(hours / 24).toFixed(1)}d`;
|
|
75
|
+
}
|
|
76
|
+
const TIME_DELTAS_SCHEMA = {
|
|
77
|
+
name: "time_appraisal",
|
|
78
|
+
schema: {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: {
|
|
81
|
+
deltas: {
|
|
82
|
+
type: "array",
|
|
83
|
+
items: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
emotion: { type: "string" },
|
|
87
|
+
delta: { type: "number" },
|
|
88
|
+
reason: { type: "string" },
|
|
89
|
+
},
|
|
90
|
+
required: ["emotion", "delta", "reason"],
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
required: ["deltas"],
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* LLM time appraisal: instead of pure exponential decay, one LLM call reasons
|
|
101
|
+
* about how the DOWNTIME felt — given how long it's been and the recent thread
|
|
102
|
+
* (short-term memory, timestamped). Feelings still generally fade toward
|
|
103
|
+
* baseline, but context can sustain or even grow them (anger that stays warm,
|
|
104
|
+
* missing someone over a long silence). Returns per-emotion deltas, applied
|
|
105
|
+
* like the event appraiser's.
|
|
106
|
+
*
|
|
107
|
+
* MUST run past the heartbeat's commit point (the Agent places it there): it
|
|
108
|
+
* makes an LLM call and is non-deterministic, so it must fire exactly once —
|
|
109
|
+
* a coalesced/cancelled heartbeat would otherwise re-run it.
|
|
110
|
+
*/
|
|
111
|
+
export class LLMTimeAppraiser {
|
|
112
|
+
llm;
|
|
113
|
+
persona;
|
|
114
|
+
minGapMinutes;
|
|
115
|
+
maxTokens;
|
|
116
|
+
constructor(options) {
|
|
117
|
+
this.llm = options.llm;
|
|
118
|
+
this.persona = options.persona;
|
|
119
|
+
this.minGapMinutes = options.minGapMinutes ?? 2;
|
|
120
|
+
this.maxTokens = options.maxTokens;
|
|
121
|
+
}
|
|
122
|
+
async appraise(input) {
|
|
123
|
+
const apply = input.apply ?? true;
|
|
124
|
+
const nowMs = input.now !== undefined ? Date.parse(input.now) : Date.now();
|
|
125
|
+
if (Number.isNaN(nowMs)) {
|
|
126
|
+
throw new TypeError(`TimeAppraisalInput.now is not a date: ${input.now}`);
|
|
127
|
+
}
|
|
128
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
129
|
+
const definitions = input.state.definitions();
|
|
130
|
+
const before = await input.state.snapshot();
|
|
131
|
+
const entries = await input.state.entries();
|
|
132
|
+
// The gap since feelings were last current. Below the threshold, decay is
|
|
133
|
+
// negligible — skip the call entirely (rapid back-and-forth isn't downtime).
|
|
134
|
+
const lastTouched = entries.reduce((max, e) => Math.max(max, Date.parse(e.updatedAt) || 0), 0);
|
|
135
|
+
const gapMs = Math.max(0, nowMs - lastTouched);
|
|
136
|
+
if (gapMs < this.minGapMinutes * 60_000) {
|
|
137
|
+
return { deltas: [], before, after: before, applied: apply };
|
|
138
|
+
}
|
|
139
|
+
const response = await this.llm.complete({
|
|
140
|
+
system: this.systemPrompt(),
|
|
141
|
+
messages: [
|
|
142
|
+
{
|
|
143
|
+
role: "user",
|
|
144
|
+
content: [{ type: "text", text: this.userPrompt(input, entries, nowIso) }],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
jsonSchema: TIME_DELTAS_SCHEMA,
|
|
148
|
+
...(this.maxTokens !== undefined && { maxTokens: this.maxTokens }),
|
|
149
|
+
});
|
|
150
|
+
const known = new Set(definitions.map((d) => d.name));
|
|
151
|
+
const deltas = parseDeltas(response.text, known);
|
|
152
|
+
const after = apply
|
|
153
|
+
? await input.state.applyDeltas(deltas)
|
|
154
|
+
: projectDeltas(before, deltas, definitions);
|
|
155
|
+
return { deltas, before, after, applied: apply };
|
|
156
|
+
}
|
|
157
|
+
systemPrompt() {
|
|
158
|
+
const identity = this.persona
|
|
159
|
+
? `You are the emotional processing of ${this.persona.name}: ${this.persona.summary}.`
|
|
160
|
+
: "You are the emotional processing of an agent.";
|
|
161
|
+
return (identity +
|
|
162
|
+
" Time has passed since the agent was last active. No new event has happened — decide how the DOWNTIME itself shifted each feeling.\n" +
|
|
163
|
+
"- Feelings fade toward baseline as time passes: a few minutes barely moves anything; an hour or two softens strong feelings; a day or more largely returns to baseline.\n" +
|
|
164
|
+
"- Context can override simple fading: dwelling on an argument can keep anger warm or let hurt grow; a long silence can grow longing or unease; excitement can settle into calm contentment.\n" +
|
|
165
|
+
"- Deltas are -1..1 and are usually gentle. Only include emotions that should move; give each a short reason that references how much time passed.\n" +
|
|
166
|
+
"- React in character — the dispositions describe how this agent tends to feel.\n" +
|
|
167
|
+
'Respond with JSON only: {"deltas": [{"emotion": string, "delta": number, "reason": string}]}.');
|
|
168
|
+
}
|
|
169
|
+
userPrompt(input, entries, nowIso) {
|
|
170
|
+
const dispositions = new Map((this.persona?.temperament ?? [])
|
|
171
|
+
.filter((t) => t.disposition)
|
|
172
|
+
.map((t) => [t.emotion, t.disposition]));
|
|
173
|
+
const byName = new Map(entries.map((e) => [e.name, e]));
|
|
174
|
+
const emotionLines = input.state.definitions().map((def) => {
|
|
175
|
+
const entry = byName.get(def.name);
|
|
176
|
+
const value = entry?.value ?? def.baseline;
|
|
177
|
+
const ago = entry ? relativeTime(entry.updatedAt, nowIso) : "unknown";
|
|
178
|
+
const disposition = dispositions.get(def.name);
|
|
179
|
+
return (`- ${def.name} (current ${value.toFixed(2)}, baseline ${def.baseline.toFixed(2)}, last moved ${ago}): ${def.description}` +
|
|
180
|
+
(disposition ? ` [disposition: ${disposition}]` : ""));
|
|
181
|
+
});
|
|
182
|
+
const recentText = renderEngagements(input.recent ?? [], nowIso);
|
|
183
|
+
return (`IT IS NOW ${nowIso}. No new event yet — only time has passed since the agent was last active.\n\n` +
|
|
184
|
+
`EMOTIONS (value 0..1; "last moved" is how long ago each was last updated):\n${emotionLines.join("\n")}\n\n` +
|
|
185
|
+
(recentText
|
|
186
|
+
? `WHAT WAS ON THE AGENT'S MIND (recent thread, most recent last; [how long ago] in brackets):\n${recentText}`
|
|
187
|
+
: "No recent thread — let feelings settle toward baseline by the elapsed time."));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ContextEvent } from "../events/context-event.js";
|
|
2
|
+
import type { Fact, FactKind } from "../memory/fact.js";
|
|
3
|
+
import type { MemoryStore } from "../memory/store.js";
|
|
4
|
+
import type { Persona } from "../emotion/persona.js";
|
|
5
|
+
import type { LLMClient } from "../llm/types.js";
|
|
6
|
+
import type { AppraisalResult } from "./appraiser.js";
|
|
7
|
+
/** A fact the extraction phase proposed, before reconciliation. */
|
|
8
|
+
export interface FactCandidate {
|
|
9
|
+
kind: FactKind;
|
|
10
|
+
content: string;
|
|
11
|
+
subjectId?: string;
|
|
12
|
+
importance: number;
|
|
13
|
+
/** Id of an existing memory the LLM says this contradicts/updates. */
|
|
14
|
+
replacesFactId?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface WriteInput {
|
|
17
|
+
event: ContextEvent;
|
|
18
|
+
/** The interpret/recall loop's final, memory-grounded description. */
|
|
19
|
+
description: string;
|
|
20
|
+
/** The memories that were recalled for this event — shown to the LLM so
|
|
21
|
+
* it only extracts NEW information and can flag contradictions. */
|
|
22
|
+
memories: Fact[];
|
|
23
|
+
/** Emotional salience guide: bigger feelings → more important memories. */
|
|
24
|
+
appraisal?: AppraisalResult;
|
|
25
|
+
persona?: Persona;
|
|
26
|
+
}
|
|
27
|
+
/** A receipt of what the write did. Callers may ignore it (the spec's
|
|
28
|
+
* "output: none") — it exists for logging, debugging, and tests. */
|
|
29
|
+
export interface WriteResult {
|
|
30
|
+
added: Fact[];
|
|
31
|
+
superseded: Array<{
|
|
32
|
+
old: Fact;
|
|
33
|
+
replacement: Fact;
|
|
34
|
+
}>;
|
|
35
|
+
skipped: Array<{
|
|
36
|
+
candidate: FactCandidate;
|
|
37
|
+
existing: Fact;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Decides what an event leaves behind in long-term memory. One method —
|
|
42
|
+
* swap the implementation and the rest of the SDK keeps working.
|
|
43
|
+
*/
|
|
44
|
+
export interface Writer {
|
|
45
|
+
write(input: WriteInput): Promise<WriteResult>;
|
|
46
|
+
}
|
|
47
|
+
export interface LLMWriterOptions {
|
|
48
|
+
llm: LLMClient;
|
|
49
|
+
store: MemoryStore;
|
|
50
|
+
/** Cap on facts extracted per event. Default 5. */
|
|
51
|
+
maxFacts?: number;
|
|
52
|
+
/** Similarity at/above which a candidate is an already-known duplicate
|
|
53
|
+
* and is skipped. Default 0.85. */
|
|
54
|
+
duplicateThreshold?: number;
|
|
55
|
+
/** Similarity at/above which a same-kind, same-subject existing fact is
|
|
56
|
+
* automatically superseded by the candidate. Default 0.5. */
|
|
57
|
+
supersedeThreshold?: number;
|
|
58
|
+
maxTokens?: number;
|
|
59
|
+
}
|
|
60
|
+
export declare const WRITE_SCHEMA: {
|
|
61
|
+
name: string;
|
|
62
|
+
schema: Record<string, unknown>;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Default writer. Two phases:
|
|
66
|
+
*
|
|
67
|
+
* 1. EXTRACTION (one LLM call) — propose standalone fact sentences with
|
|
68
|
+
* kind/subject/importance, flagging which shown memory each one
|
|
69
|
+
* contradicts ("replaces"), if any.
|
|
70
|
+
* 2. RECONCILIATION (no LLM) — per candidate, search memory for lookalikes:
|
|
71
|
+
* near-duplicates are skipped; LLM-flagged contradictions supersede the
|
|
72
|
+
* old fact; same-kind/same-subject near-matches auto-supersede (except
|
|
73
|
+
* `event` facts, which are history and never auto-superseded); the rest
|
|
74
|
+
* are stored as new. Superseded facts are kept, pointing at their
|
|
75
|
+
* replacement. Every stored fact carries metadata.sourceEventId.
|
|
76
|
+
*/
|
|
77
|
+
export declare class LLMWriter implements Writer {
|
|
78
|
+
private readonly llm;
|
|
79
|
+
private readonly store;
|
|
80
|
+
private readonly maxFacts;
|
|
81
|
+
private readonly duplicateThreshold;
|
|
82
|
+
private readonly supersedeThreshold;
|
|
83
|
+
private readonly maxTokens;
|
|
84
|
+
constructor(options: LLMWriterOptions);
|
|
85
|
+
write(input: WriteInput): Promise<WriteResult>;
|
|
86
|
+
private systemPrompt;
|
|
87
|
+
private userPrompt;
|
|
88
|
+
}
|
|
89
|
+
/** Everything {@link reconcile} needs to fold one candidate into the store. */
|
|
90
|
+
export interface ReconcileContext {
|
|
91
|
+
store: MemoryStore;
|
|
92
|
+
/** At/above this similarity, the candidate is a known duplicate (skipped). */
|
|
93
|
+
duplicateThreshold: number;
|
|
94
|
+
/** At/above this, a same-kind/same-subject existing fact is superseded. */
|
|
95
|
+
supersedeThreshold: number;
|
|
96
|
+
/** Stamped onto the stored fact's metadata for provenance. */
|
|
97
|
+
sourceEventId: string;
|
|
98
|
+
/** The memories shown to the LLM, for resolving an LLM-directed `replaces`. */
|
|
99
|
+
shownMemories: Fact[];
|
|
100
|
+
/** Extra metadata merged into the stored fact (e.g. which stage wrote it). */
|
|
101
|
+
extraMetadata?: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Fold one candidate fact into memory, shared by the writer and the
|
|
105
|
+
* consolidator: skip near-duplicates; store the rest; supersede an
|
|
106
|
+
* LLM-flagged contradiction, or a same-kind/same-subject near-match (never an
|
|
107
|
+
* `event` — those are history). Mutates `result` with what happened.
|
|
108
|
+
*/
|
|
109
|
+
export declare function reconcile(candidate: FactCandidate, ctx: ReconcileContext, result: WriteResult): Promise<void>;
|
|
110
|
+
/** Tolerant parse of extraction output. Resolves `replaces` numbers against
|
|
111
|
+
* the shown memories; drops malformed entries; clamps importance; caps the
|
|
112
|
+
* batch. Unparseable output yields an empty list (nothing remembered). */
|
|
113
|
+
export declare function parseCandidates(text: string, shownMemories: Fact[], maxFacts: number): FactCandidate[];
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { clamp01 } from "../memory/scoring.js";
|
|
2
|
+
export const WRITE_SCHEMA = {
|
|
3
|
+
name: "memory_write",
|
|
4
|
+
schema: {
|
|
5
|
+
type: "object",
|
|
6
|
+
properties: {
|
|
7
|
+
facts: {
|
|
8
|
+
type: "array",
|
|
9
|
+
items: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
kind: { type: "string" },
|
|
13
|
+
content: { type: "string" },
|
|
14
|
+
subject: { type: ["string", "null"] },
|
|
15
|
+
importance: { type: "number" },
|
|
16
|
+
replaces: { type: ["integer", "null"] },
|
|
17
|
+
},
|
|
18
|
+
required: ["kind", "content", "subject", "importance", "replaces"],
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["facts"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Default writer. Two phases:
|
|
29
|
+
*
|
|
30
|
+
* 1. EXTRACTION (one LLM call) — propose standalone fact sentences with
|
|
31
|
+
* kind/subject/importance, flagging which shown memory each one
|
|
32
|
+
* contradicts ("replaces"), if any.
|
|
33
|
+
* 2. RECONCILIATION (no LLM) — per candidate, search memory for lookalikes:
|
|
34
|
+
* near-duplicates are skipped; LLM-flagged contradictions supersede the
|
|
35
|
+
* old fact; same-kind/same-subject near-matches auto-supersede (except
|
|
36
|
+
* `event` facts, which are history and never auto-superseded); the rest
|
|
37
|
+
* are stored as new. Superseded facts are kept, pointing at their
|
|
38
|
+
* replacement. Every stored fact carries metadata.sourceEventId.
|
|
39
|
+
*/
|
|
40
|
+
export class LLMWriter {
|
|
41
|
+
llm;
|
|
42
|
+
store;
|
|
43
|
+
maxFacts;
|
|
44
|
+
duplicateThreshold;
|
|
45
|
+
supersedeThreshold;
|
|
46
|
+
maxTokens;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.llm = options.llm;
|
|
49
|
+
this.store = options.store;
|
|
50
|
+
this.maxFacts = options.maxFacts ?? 5;
|
|
51
|
+
this.duplicateThreshold = options.duplicateThreshold ?? 0.85;
|
|
52
|
+
this.supersedeThreshold = options.supersedeThreshold ?? 0.5;
|
|
53
|
+
this.maxTokens = options.maxTokens;
|
|
54
|
+
}
|
|
55
|
+
async write(input) {
|
|
56
|
+
const response = await this.llm.complete({
|
|
57
|
+
system: this.systemPrompt(input.persona),
|
|
58
|
+
messages: [
|
|
59
|
+
{ role: "user", content: [{ type: "text", text: this.userPrompt(input) }] },
|
|
60
|
+
],
|
|
61
|
+
jsonSchema: WRITE_SCHEMA,
|
|
62
|
+
...(this.maxTokens !== undefined && { maxTokens: this.maxTokens }),
|
|
63
|
+
});
|
|
64
|
+
const candidates = parseCandidates(response.text, input.memories, this.maxFacts);
|
|
65
|
+
const result = { added: [], superseded: [], skipped: [] };
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
await reconcile(candidate, {
|
|
68
|
+
store: this.store,
|
|
69
|
+
duplicateThreshold: this.duplicateThreshold,
|
|
70
|
+
supersedeThreshold: this.supersedeThreshold,
|
|
71
|
+
sourceEventId: input.event.id,
|
|
72
|
+
shownMemories: input.memories,
|
|
73
|
+
}, result);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
systemPrompt(persona) {
|
|
78
|
+
const identity = persona
|
|
79
|
+
? `You are the memory-encoding system of ${persona.name}: ${persona.summary}.`
|
|
80
|
+
: "You are the memory-encoding system of an agent.";
|
|
81
|
+
return (identity +
|
|
82
|
+
" Your job is to EXTRACT durable facts worth remembering — not to summarize or restate what happened. Read the account below and pull out the concrete information that will still matter in future conversations. Most messages leave behind little or nothing.\n" +
|
|
83
|
+
"RULES:\n" +
|
|
84
|
+
'- EXTRACT, don\'t transcribe: store the underlying fact, never the message or the narrative. "yo wsg it\'s Roy" leaves behind ONE fact — that the person\'s name is Roy — NOT that they greeted you or asked how you are.\n' +
|
|
85
|
+
'- ATOMIC: each fact is exactly ONE piece of information. Never join two things with "and"; split them into separate facts.\n' +
|
|
86
|
+
"- DURABLE ONLY: keep concrete, lasting information — names, identity, relationships, stable preferences, traits, commitments, and significant dated events.\n" +
|
|
87
|
+
"- NEVER store any of these — they are NOT facts: the agent's own feelings, mood, or emotional reaction; the agent's persona or a trait it already has (the persona is given, not learned); atmosphere, tone, or inferred sentiment (e.g. \"camaraderie\", \"a welcome connection\", \"reinforces his fondness\"); or the mechanics of the exchange (that a message arrived, that someone greeted you or asked a question).\n" +
|
|
88
|
+
"- Record only what is concretely stated or unambiguously implied by the speaker — never the account's embellishment, mood, or vibe, and never something you inferred to be likely.\n" +
|
|
89
|
+
"- NEW ONLY: record nothing already covered by KNOWN MEMORIES. If the event corrects or updates a known memory, output the corrected fact and set `replaces` to that memory's number.\n" +
|
|
90
|
+
'- STANDALONE: each fact must make sense years from now with no surrounding context — use real names (never pronouns like "he", "they", or "the user") and absolute dates (the event time is provided).\n' +
|
|
91
|
+
'- kind: "persona" (who the agent is — rare), "self" (RARE: a concrete, durable thing the agent newly LEARNED about itself through what happened — never a mood, introspection, or a trait it already has), "user" (durable information about a person), "event" (a real-world happening worth remembering — a move, a new job, an injury, a milestone). A turn in THIS conversation is never an event: a greeting, a question someone asked, or a message being sent or received is NOT an event.\n' +
|
|
92
|
+
"- importance 0..1: trivia ~0.2, ordinary ~0.4-0.6, identity-defining (names, relationships) or emotionally significant 0.7-0.9. The EMOTIONAL IMPACT section is only a guide to importance — it is never something to store.\n" +
|
|
93
|
+
"- Prefer a FEW high-quality facts. An empty list is the correct — and common — answer when nothing durable was shared.\n" +
|
|
94
|
+
"HOW TO EXTRACT (account → what to store):\n" +
|
|
95
|
+
'- "Roy greets you warmly and introduces himself; you feel a flash of affection." → one user fact: the person\'s name is Roy. (drop the greeting AND the feeling)\n' +
|
|
96
|
+
'- "Maya mentions her sister Dana borrowed her car last Tuesday and still hasn\'t returned it." → two facts: Maya has a sister named Dana; and a dated event — Dana borrowed Maya\'s car and has not returned it.\n' +
|
|
97
|
+
'- "An old friend asks how your week is going, which lifts your mood." → nothing durable; store nothing. (a question and a mood are not facts)\n' +
|
|
98
|
+
'Respond with JSON only: {"facts": [{"kind": string, "content": string, "subject": string|null, "importance": number, "replaces": integer|null}]}.');
|
|
99
|
+
}
|
|
100
|
+
userPrompt(input) {
|
|
101
|
+
const actor = input.event.actor.displayName
|
|
102
|
+
? `${input.event.actor.displayName} (id ${input.event.actor.id})`
|
|
103
|
+
: `id ${input.event.actor.id}`;
|
|
104
|
+
const deltas = input.appraisal?.deltas ?? [];
|
|
105
|
+
const emotionalImpact = deltas.length === 0
|
|
106
|
+
? "none notable"
|
|
107
|
+
: deltas
|
|
108
|
+
.map((d) => `${d.emotion} ${d.delta >= 0 ? "+" : ""}${d.delta.toFixed(2)}` +
|
|
109
|
+
(d.reason ? ` — ${d.reason}` : ""))
|
|
110
|
+
.join("\n");
|
|
111
|
+
const known = input.memories.length === 0
|
|
112
|
+
? "none"
|
|
113
|
+
: input.memories
|
|
114
|
+
.map((fact, i) => `${i + 1}. [${fact.kind}] ${fact.content}`)
|
|
115
|
+
.join("\n");
|
|
116
|
+
return (`EVENT — platform: ${input.event.platform}; actor: ${actor}; time: ${input.event.occurredAt}.\n` +
|
|
117
|
+
`If a fact is about the actor, set "subject" to "${input.event.actor.id}".\n\n` +
|
|
118
|
+
`ACCOUNT OF WHAT HAPPENED (extract the durable facts from this; most of it is not worth storing):\n${input.description}\n\n` +
|
|
119
|
+
`EMOTIONAL IMPACT (how strongly the agent reacted — use ONLY to weight importance; never store any of this as a fact):\n${emotionalImpact}\n\n` +
|
|
120
|
+
`KNOWN MEMORIES:\n${known}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Fold one candidate fact into memory, shared by the writer and the
|
|
125
|
+
* consolidator: skip near-duplicates; store the rest; supersede an
|
|
126
|
+
* LLM-flagged contradiction, or a same-kind/same-subject near-match (never an
|
|
127
|
+
* `event` — those are history). Mutates `result` with what happened.
|
|
128
|
+
*/
|
|
129
|
+
export async function reconcile(candidate, ctx, result) {
|
|
130
|
+
const similar = await ctx.store.fetch({ text: candidate.content, limit: 3 });
|
|
131
|
+
const top = similar[0];
|
|
132
|
+
if (top && top.relevance >= ctx.duplicateThreshold) {
|
|
133
|
+
result.skipped.push({ candidate, existing: top.fact });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const [fact] = await ctx.store.add([
|
|
137
|
+
{
|
|
138
|
+
kind: candidate.kind,
|
|
139
|
+
content: candidate.content,
|
|
140
|
+
importance: candidate.importance,
|
|
141
|
+
...(candidate.subjectId !== undefined && { subjectId: candidate.subjectId }),
|
|
142
|
+
metadata: { sourceEventId: ctx.sourceEventId, ...ctx.extraMetadata },
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
result.added.push(fact);
|
|
146
|
+
// LLM-directed supersede takes precedence over the similarity heuristic.
|
|
147
|
+
let target;
|
|
148
|
+
if (candidate.replacesFactId !== undefined) {
|
|
149
|
+
target = ctx.shownMemories.find((m) => m.id === candidate.replacesFactId);
|
|
150
|
+
}
|
|
151
|
+
else if (top &&
|
|
152
|
+
top.relevance >= ctx.supersedeThreshold &&
|
|
153
|
+
top.fact.kind === candidate.kind &&
|
|
154
|
+
top.fact.kind !== "event" &&
|
|
155
|
+
top.fact.subjectId === candidate.subjectId) {
|
|
156
|
+
target = top.fact;
|
|
157
|
+
}
|
|
158
|
+
if (target && target.supersededBy === undefined) {
|
|
159
|
+
const updated = await ctx.store.update(target.id, { supersededBy: fact.id });
|
|
160
|
+
if (updated) {
|
|
161
|
+
result.superseded.push({ old: updated, replacement: fact });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Tolerant parse of extraction output. Resolves `replaces` numbers against
|
|
166
|
+
* the shown memories; drops malformed entries; clamps importance; caps the
|
|
167
|
+
* batch. Unparseable output yields an empty list (nothing remembered). */
|
|
168
|
+
export function parseCandidates(text, shownMemories, maxFacts) {
|
|
169
|
+
const start = text.indexOf("{");
|
|
170
|
+
const end = text.lastIndexOf("}");
|
|
171
|
+
if (start === -1 || end <= start)
|
|
172
|
+
return [];
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
175
|
+
if (!Array.isArray(parsed.facts))
|
|
176
|
+
return [];
|
|
177
|
+
const candidates = [];
|
|
178
|
+
for (const item of parsed.facts) {
|
|
179
|
+
if (candidates.length >= maxFacts)
|
|
180
|
+
break;
|
|
181
|
+
if (typeof item !== "object" || item === null)
|
|
182
|
+
continue;
|
|
183
|
+
const { kind, content, subject, importance, replaces } = item;
|
|
184
|
+
if (typeof kind !== "string" || kind.trim() === "")
|
|
185
|
+
continue;
|
|
186
|
+
if (typeof content !== "string" || content.trim() === "")
|
|
187
|
+
continue;
|
|
188
|
+
const replacesIndex = typeof replaces === "number" && Number.isInteger(replaces)
|
|
189
|
+
? replaces
|
|
190
|
+
: undefined;
|
|
191
|
+
const replacesFact = replacesIndex !== undefined &&
|
|
192
|
+
replacesIndex >= 1 &&
|
|
193
|
+
replacesIndex <= shownMemories.length
|
|
194
|
+
? shownMemories[replacesIndex - 1]
|
|
195
|
+
: undefined;
|
|
196
|
+
candidates.push({
|
|
197
|
+
kind: kind.trim(),
|
|
198
|
+
content: content.trim(),
|
|
199
|
+
importance: clamp01(typeof importance === "number" ? importance : 0.5),
|
|
200
|
+
...(typeof subject === "string" &&
|
|
201
|
+
subject.trim() !== "" && { subjectId: subject.trim() }),
|
|
202
|
+
...(replacesFact !== undefined && { replacesFactId: replacesFact.id }),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return candidates;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
import { Agent, type AgentOptions, type AgentResult } from "../agent.js";
|
|
3
|
+
import type { ContextEvent } from "../events/context-event.js";
|
|
4
|
+
import type { EnqueueOutcome } from "../pipeline/inbox.js";
|
|
5
|
+
import type { LLMClient } from "../llm/types.js";
|
|
6
|
+
import type { Embedder } from "../memory/store.js";
|
|
7
|
+
import { type ActionExecutor, type AgentBlueprint } from "../blueprint/blueprint.js";
|
|
8
|
+
import { type InstanceScope } from "../blueprint/scope.js";
|
|
9
|
+
import { PostgresMemoryStore } from "./store.js";
|
|
10
|
+
import { PostgresEmotionalState } from "./emotional-state.js";
|
|
11
|
+
import { PostgresShortTermMemory } from "./short-term.js";
|
|
12
|
+
/** Pipeline customizations forwarded to the Agent untouched — everything
|
|
13
|
+
* that isn't decided by the blueprint or owned by this factory. */
|
|
14
|
+
type AgentOverrides = Omit<AgentOptions, "llm" | "memory" | "emotions" | "actions" | "persona" | "act">;
|
|
15
|
+
export interface CreateAgentInstanceOptions {
|
|
16
|
+
/** The character, as authored in the studio. */
|
|
17
|
+
blueprint: AgentBlueprint;
|
|
18
|
+
/** Whose instance this is. With `blueprint.id`, determines all state.
|
|
19
|
+
* OMIT to mint a brand-new instance with a generated id (read it back off
|
|
20
|
+
* `result.scope.userId`); PASS an existing id to get that instance's
|
|
21
|
+
* shared live object (see {@link createAgentInstance}). */
|
|
22
|
+
userId?: string;
|
|
23
|
+
/** Reserved for shared-instance sessions; omit today. */
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
llm: LLMClient;
|
|
26
|
+
/** Side effect for each blueprint action, by name. Required when the
|
|
27
|
+
* blueprint declares actions. */
|
|
28
|
+
executors?: Record<string, ActionExecutor>;
|
|
29
|
+
pool?: pg.Pool;
|
|
30
|
+
connectionString?: string;
|
|
31
|
+
ssl?: pg.PoolConfig["ssl"];
|
|
32
|
+
/** Forwarded to PostgresMemoryStore. */
|
|
33
|
+
embedder?: Embedder;
|
|
34
|
+
/** Pipeline-stage overrides, forwarded to the Agent (e.g. a processor on
|
|
35
|
+
* a different LLM client for split billing). */
|
|
36
|
+
overrides?: AgentOverrides;
|
|
37
|
+
}
|
|
38
|
+
/** A ready-to-use agent instance plus handles to its state. */
|
|
39
|
+
export interface AgentInstance {
|
|
40
|
+
agent: Agent;
|
|
41
|
+
/** Funnel an event in: serialized + coalesced per instance. The production
|
|
42
|
+
* entry point — delegates to `agent.enqueue`. Resolves to the heartbeat
|
|
43
|
+
* result, or a merged marker when this event was folded into a later one. */
|
|
44
|
+
enqueue(event: ContextEvent): Promise<EnqueueOutcome<AgentResult>>;
|
|
45
|
+
scope: InstanceScope;
|
|
46
|
+
/** Canonical storage key — every row of this instance's state carries it. */
|
|
47
|
+
key: string;
|
|
48
|
+
/** True when the build birthed the instance (claimed the directory row and
|
|
49
|
+
* wrote seed facts). Set once at build time; a cached return carries that
|
|
50
|
+
* same value, so it means "birthed during this process," not "by this call." */
|
|
51
|
+
created: boolean;
|
|
52
|
+
memory: PostgresMemoryStore;
|
|
53
|
+
emotions: PostgresEmotionalState;
|
|
54
|
+
shortTermMemory: PostgresShortTermMemory;
|
|
55
|
+
/** Forget this instance from the registry, and end the pool if this build
|
|
56
|
+
* created it. Idempotent. With a shared pool you own, this only evicts the
|
|
57
|
+
* cache entry — you end the pool yourself at shutdown. */
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Forget every cached instance. Does NOT close pools (you own those) — it
|
|
62
|
+
* just empties the in-memory lookup so the next `createAgentInstance` rebuilds
|
|
63
|
+
* from the database. Blunt: for tests and full teardown only — to drop a
|
|
64
|
+
* single edited instance use {@link forgetInstance}.
|
|
65
|
+
*/
|
|
66
|
+
export declare function clearInstanceRegistry(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Forget ONE instance from the registry. The next `createAgentInstance` for
|
|
69
|
+
* that id rebuilds it from the database — picking up an edited blueprint
|
|
70
|
+
* (new persona, model, actions) that the cached object was frozen with. Every
|
|
71
|
+
* other instance is untouched. Returns true if an entry was removed.
|
|
72
|
+
*
|
|
73
|
+
* Call this when the instance is IDLE — e.g. after a blueprint edit, between
|
|
74
|
+
* events. Forgetting one that has a queued or in-flight heartbeat transiently
|
|
75
|
+
* splits its queue (the running heartbeat keeps the old object while new
|
|
76
|
+
* events build a fresh one), reopening the serialization the registry exists
|
|
77
|
+
* to provide.
|
|
78
|
+
*/
|
|
79
|
+
export declare function forgetInstance(scope: InstanceScope): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Forget every cached instance of a blueprint — all its users and sessions —
|
|
82
|
+
* so the next `createAgentInstance` for any of them rebuilds from the
|
|
83
|
+
* database, applying an edited blueprint (new persona, model, actions). Other
|
|
84
|
+
* blueprints are untouched. Returns how many instances were forgotten. Same
|
|
85
|
+
* idle caveat as {@link forgetInstance}: forgetting an instance mid-heartbeat
|
|
86
|
+
* transiently splits its queue.
|
|
87
|
+
*/
|
|
88
|
+
export declare function forgetBlueprint(blueprintId: string): number;
|
|
89
|
+
/**
|
|
90
|
+
* The one-call entry point: blueprint + user → that user's instance, as a
|
|
91
|
+
* shared live object.
|
|
92
|
+
*
|
|
93
|
+
* - **Omit `userId`** to MINT a new instance: a fresh id is generated, the
|
|
94
|
+
* instance is birthed, and you read the id back off `result.scope.userId`
|
|
95
|
+
* to address it later. (Same rule will extend to `sessionId`.)
|
|
96
|
+
* - **Pass a `userId`** to GET that instance: the first call this process
|
|
97
|
+
* makes for an id builds it (birthing it in the DB on true first contact,
|
|
98
|
+
* or attaching to existing persistent state otherwise) and caches the live
|
|
99
|
+
* object; every later call with the same id returns that **same object**.
|
|
100
|
+
*
|
|
101
|
+
* Births write seed facts and claim a directory row exactly once; lived state
|
|
102
|
+
* is never re-seeded. Two ids share nothing. Pass one shared `pool` you own
|
|
103
|
+
* (recommended) — then per-instance `close()` only forgets the cache entry;
|
|
104
|
+
* you end the pool yourself at shutdown.
|
|
105
|
+
*/
|
|
106
|
+
export declare function createAgentInstance(options: CreateAgentInstanceOptions): Promise<AgentInstance>;
|
|
107
|
+
export {};
|