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,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 {};