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,182 @@
1
+ import pg from "pg";
2
+ import { Agent } from "../agent.js";
3
+ import { generateEventId } from "../events/context-event.js";
4
+ import { compileActions, } from "../blueprint/blueprint.js";
5
+ import { instanceKey } from "../blueprint/scope.js";
6
+ import { PostgresMemoryStore } from "./store.js";
7
+ import { PostgresEmotionalState } from "./emotional-state.js";
8
+ import { PostgresShortTermMemory } from "./short-term.js";
9
+ import { claimInstance, releaseInstance } from "./instances.js";
10
+ /**
11
+ * Process-level registry of live instances, keyed by instanceKey. Holds the
12
+ * in-flight Promise (not the resolved value) so two simultaneous first-calls
13
+ * for the same id share ONE build instead of racing two objects into being.
14
+ * This is what lets the same id return the SAME in-memory object — the
15
+ * prerequisite for any per-instance state (a request queue, caches) to work.
16
+ */
17
+ const registry = new Map();
18
+ /**
19
+ * Forget every cached instance. Does NOT close pools (you own those) — it
20
+ * just empties the in-memory lookup so the next `createAgentInstance` rebuilds
21
+ * from the database. Blunt: for tests and full teardown only — to drop a
22
+ * single edited instance use {@link forgetInstance}.
23
+ */
24
+ export function clearInstanceRegistry() {
25
+ registry.clear();
26
+ }
27
+ /**
28
+ * Forget ONE instance from the registry. The next `createAgentInstance` for
29
+ * that id rebuilds it from the database — picking up an edited blueprint
30
+ * (new persona, model, actions) that the cached object was frozen with. Every
31
+ * other instance is untouched. Returns true if an entry was removed.
32
+ *
33
+ * Call this when the instance is IDLE — e.g. after a blueprint edit, between
34
+ * events. Forgetting one that has a queued or in-flight heartbeat transiently
35
+ * splits its queue (the running heartbeat keeps the old object while new
36
+ * events build a fresh one), reopening the serialization the registry exists
37
+ * to provide.
38
+ */
39
+ export function forgetInstance(scope) {
40
+ return registry.delete(instanceKey(scope));
41
+ }
42
+ /**
43
+ * Forget every cached instance of a blueprint — all its users and sessions —
44
+ * so the next `createAgentInstance` for any of them rebuilds from the
45
+ * database, applying an edited blueprint (new persona, model, actions). Other
46
+ * blueprints are untouched. Returns how many instances were forgotten. Same
47
+ * idle caveat as {@link forgetInstance}: forgetting an instance mid-heartbeat
48
+ * transiently splits its queue.
49
+ */
50
+ export function forgetBlueprint(blueprintId) {
51
+ const prefix = encodeURIComponent(blueprintId) + "/";
52
+ let forgotten = 0;
53
+ for (const key of [...registry.keys()]) {
54
+ if (key.startsWith(prefix)) {
55
+ registry.delete(key);
56
+ forgotten++;
57
+ }
58
+ }
59
+ return forgotten;
60
+ }
61
+ /**
62
+ * The one-call entry point: blueprint + user → that user's instance, as a
63
+ * shared live object.
64
+ *
65
+ * - **Omit `userId`** to MINT a new instance: a fresh id is generated, the
66
+ * instance is birthed, and you read the id back off `result.scope.userId`
67
+ * to address it later. (Same rule will extend to `sessionId`.)
68
+ * - **Pass a `userId`** to GET that instance: the first call this process
69
+ * makes for an id builds it (birthing it in the DB on true first contact,
70
+ * or attaching to existing persistent state otherwise) and caches the live
71
+ * object; every later call with the same id returns that **same object**.
72
+ *
73
+ * Births write seed facts and claim a directory row exactly once; lived state
74
+ * is never re-seeded. Two ids share nothing. Pass one shared `pool` you own
75
+ * (recommended) — then per-instance `close()` only forgets the cache entry;
76
+ * you end the pool yourself at shutdown.
77
+ */
78
+ export async function createAgentInstance(options) {
79
+ const scope = {
80
+ blueprintId: options.blueprint.id,
81
+ userId: options.userId ?? generateEventId(),
82
+ ...(options.sessionId !== undefined && { sessionId: options.sessionId }),
83
+ };
84
+ const key = instanceKey(scope); // validates the scope before any I/O
85
+ const existing = registry.get(key);
86
+ if (existing)
87
+ return existing;
88
+ // Wrap the build so the instance's close() also evicts THIS promise from
89
+ // the registry (only if the slot still points here — a rebuild after a
90
+ // clear must not be clobbered by a stale close).
91
+ const building = buildInstance(options, scope, key).then((instance) => {
92
+ const poolClose = instance.close;
93
+ instance.close = async () => {
94
+ if (registry.get(key) === building)
95
+ registry.delete(key);
96
+ await poolClose();
97
+ };
98
+ return instance;
99
+ });
100
+ registry.set(key, building);
101
+ // A failed build must not poison the slot: forget it so a later call retries.
102
+ building.catch(() => {
103
+ if (registry.get(key) === building)
104
+ registry.delete(key);
105
+ });
106
+ return building;
107
+ }
108
+ /** Construct one instance from scratch (no registry lookup). */
109
+ async function buildInstance(options, scope, key) {
110
+ const { blueprint } = options;
111
+ let pool;
112
+ let ownsPool;
113
+ if (options.pool) {
114
+ pool = options.pool;
115
+ ownsPool = false;
116
+ }
117
+ else {
118
+ const connectionString = options.connectionString ?? process.env.DATABASE_URL;
119
+ if (!connectionString) {
120
+ throw new Error("createAgentInstance needs a `connectionString` (or DATABASE_URL env var) or an existing `pool`.");
121
+ }
122
+ pool = new pg.Pool({
123
+ connectionString,
124
+ ...(options.ssl !== undefined && { ssl: options.ssl }),
125
+ });
126
+ ownsPool = true;
127
+ }
128
+ const memory = new PostgresMemoryStore({
129
+ pool,
130
+ scope,
131
+ ...(options.embedder !== undefined && { embedder: options.embedder }),
132
+ });
133
+ const emotions = new PostgresEmotionalState({
134
+ pool,
135
+ scope,
136
+ persona: blueprint.persona,
137
+ ...(blueprint.emotions !== undefined && { emotions: blueprint.emotions }),
138
+ });
139
+ const shortTermMemory = new PostgresShortTermMemory({ pool, scope });
140
+ const created = await claimInstance(pool, scope, blueprint.version);
141
+ if (created && blueprint.seedFacts !== undefined && blueprint.seedFacts.length > 0) {
142
+ try {
143
+ await memory.add(blueprint.seedFacts);
144
+ }
145
+ catch (error) {
146
+ // Give back the claim so a retry re-seeds instead of skipping.
147
+ await releaseInstance(pool, scope);
148
+ if (ownsPool)
149
+ await pool.end();
150
+ throw error;
151
+ }
152
+ }
153
+ const agent = new Agent({
154
+ llm: options.llm,
155
+ memory,
156
+ emotions,
157
+ shortTermMemory,
158
+ actions: compileActions(blueprint.actions, options.executors),
159
+ persona: blueprint.persona,
160
+ ...options.overrides,
161
+ });
162
+ let closed = false;
163
+ return {
164
+ agent,
165
+ enqueue: (event) => agent.enqueue(event),
166
+ scope,
167
+ key,
168
+ created,
169
+ memory,
170
+ emotions,
171
+ shortTermMemory,
172
+ // Pool teardown only; createAgentInstance wraps this to also evict from
173
+ // the registry. Idempotent — a shared object may be closed more than once.
174
+ close: async () => {
175
+ if (closed)
176
+ return;
177
+ closed = true;
178
+ if (ownsPool)
179
+ await pool.end();
180
+ },
181
+ };
182
+ }
@@ -0,0 +1,42 @@
1
+ import pg from "pg";
2
+ import { type EmotionDefinition, type EmotionDelta, type EmotionSnapshot } from "../emotion/definitions.js";
3
+ import { type Persona } from "../emotion/persona.js";
4
+ import { type EmotionEntry, type EmotionalState } from "../emotion/state.js";
5
+ import { type InstanceScope } from "../blueprint/scope.js";
6
+ export interface PostgresEmotionalStateOptions {
7
+ /** e.g. process.env.DATABASE_URL. Either this or `pool` is required. */
8
+ connectionString?: string;
9
+ /** Bring your own pg Pool (the store will not close it). */
10
+ pool?: pg.Pool;
11
+ ssl?: pg.PoolConfig["ssl"];
12
+ /** Which instance's feelings this is. One database serves many
13
+ * instances; every query is filtered by this scope's key. */
14
+ scope: InstanceScope;
15
+ /** The palette. Default DEFAULT_EMOTIONS. */
16
+ emotions?: readonly EmotionDefinition[];
17
+ /** Temperament (sensitivity/baseline overrides) is merged into the
18
+ * palette; summary/dispositions are used by the appraiser, not here. */
19
+ persona?: Persona;
20
+ }
21
+ /**
22
+ * Persistent emotional state: one Postgres row per slider per instance, so
23
+ * feelings survive restarts and deploys. Rows are seeded from baselines on
24
+ * first use; emotions added to the palette later get seeded on next startup
25
+ * while existing values are left untouched.
26
+ */
27
+ export declare class PostgresEmotionalState implements EmotionalState {
28
+ private readonly pool;
29
+ private readonly ownsPool;
30
+ private readonly instanceKey;
31
+ private readonly defs;
32
+ private ready;
33
+ constructor(options: PostgresEmotionalStateOptions);
34
+ definitions(): EmotionDefinition[];
35
+ snapshot(): Promise<EmotionSnapshot>;
36
+ entries(): Promise<EmotionEntry[]>;
37
+ set(values: Partial<EmotionSnapshot>, at?: string): Promise<EmotionSnapshot>;
38
+ applyDeltas(deltas: EmotionDelta[]): Promise<EmotionSnapshot>;
39
+ close(): Promise<void>;
40
+ private ensureReady;
41
+ private migrate;
42
+ }
@@ -0,0 +1,134 @@
1
+ import pg from "pg";
2
+ import { DEFAULT_EMOTIONS, } from "../emotion/definitions.js";
3
+ import { applyTemperament } from "../emotion/persona.js";
4
+ import { projectDeltas, } from "../emotion/state.js";
5
+ import { clamp01 } from "../memory/scoring.js";
6
+ import { instanceKey } from "../blueprint/scope.js";
7
+ import { EMOTIONS_SCHEMA } from "./schema.js";
8
+ /**
9
+ * Persistent emotional state: one Postgres row per slider per instance, so
10
+ * feelings survive restarts and deploys. Rows are seeded from baselines on
11
+ * first use; emotions added to the palette later get seeded on next startup
12
+ * while existing values are left untouched.
13
+ */
14
+ export class PostgresEmotionalState {
15
+ pool;
16
+ ownsPool;
17
+ instanceKey;
18
+ defs;
19
+ ready;
20
+ constructor(options) {
21
+ if (options.pool) {
22
+ this.pool = options.pool;
23
+ this.ownsPool = false;
24
+ }
25
+ else {
26
+ const connectionString = options.connectionString ?? process.env.DATABASE_URL;
27
+ if (!connectionString) {
28
+ throw new Error("PostgresEmotionalState needs a `connectionString` (or DATABASE_URL env var) or an existing `pool`.");
29
+ }
30
+ this.pool = new pg.Pool({
31
+ connectionString,
32
+ ...(options.ssl !== undefined && { ssl: options.ssl }),
33
+ });
34
+ this.ownsPool = true;
35
+ }
36
+ this.instanceKey = instanceKey(options.scope);
37
+ this.defs = applyTemperament(options.emotions ?? DEFAULT_EMOTIONS, options.persona);
38
+ if (this.defs.length === 0) {
39
+ throw new Error("PostgresEmotionalState needs at least one emotion.");
40
+ }
41
+ }
42
+ definitions() {
43
+ return this.defs.map((d) => ({ ...d }));
44
+ }
45
+ async snapshot() {
46
+ await this.ensureReady();
47
+ const { rows } = await this.pool.query(`SELECT emotion, value FROM emocentric_emotions WHERE instance_key = $1`, [this.instanceKey]);
48
+ const stored = new Map(rows.map((r) => [r.emotion, r.value]));
49
+ // Snapshot is shaped by the palette: emotions no longer in it are
50
+ // omitted; palette emotions missing a row fall back to baseline.
51
+ const snapshot = {};
52
+ for (const def of this.defs) {
53
+ snapshot[def.name] = stored.get(def.name) ?? def.baseline;
54
+ }
55
+ return snapshot;
56
+ }
57
+ async entries() {
58
+ await this.ensureReady();
59
+ const { rows } = await this.pool.query(`SELECT emotion, value, updated_at FROM emocentric_emotions WHERE instance_key = $1`, [this.instanceKey]);
60
+ const stored = new Map(rows.map((r) => [r.emotion, r]));
61
+ const nowIso = new Date().toISOString();
62
+ return this.defs.map((def) => {
63
+ const row = stored.get(def.name);
64
+ return row
65
+ ? {
66
+ name: def.name,
67
+ value: row.value,
68
+ updatedAt: row.updated_at.toISOString(),
69
+ }
70
+ : { name: def.name, value: def.baseline, updatedAt: nowIso };
71
+ });
72
+ }
73
+ async set(values, at) {
74
+ await this.ensureReady();
75
+ const known = new Set(this.defs.map((d) => d.name));
76
+ const stamp = at ?? new Date().toISOString();
77
+ const names = [];
78
+ const clamped = [];
79
+ for (const [name, value] of Object.entries(values)) {
80
+ if (!known.has(name) || typeof value !== "number")
81
+ continue;
82
+ names.push(name);
83
+ clamped.push(clamp01(value));
84
+ }
85
+ if (names.length > 0) {
86
+ // One multi-row upsert instead of a query per slider: the whole write
87
+ // lands atomically and in a single round-trip.
88
+ await this.pool.query(`INSERT INTO emocentric_emotions (instance_key, emotion, value, updated_at)
89
+ SELECT $1, t.emotion, t.value, $2::timestamptz
90
+ FROM unnest($3::text[], $4::real[]) AS t(emotion, value)
91
+ ON CONFLICT (instance_key, emotion)
92
+ DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`, [this.instanceKey, stamp, names, clamped]);
93
+ }
94
+ return this.snapshot();
95
+ }
96
+ async applyDeltas(deltas) {
97
+ const before = await this.snapshot();
98
+ const after = projectDeltas(before, deltas, this.defs);
99
+ const touched = this.defs
100
+ .map((d) => d.name)
101
+ .filter((name) => after[name] !== before[name]);
102
+ if (touched.length > 0) {
103
+ const now = new Date().toISOString();
104
+ for (const name of touched) {
105
+ await this.pool.query(`INSERT INTO emocentric_emotions (instance_key, emotion, value, updated_at)
106
+ VALUES ($1, $2, $3, $4)
107
+ ON CONFLICT (instance_key, emotion)
108
+ DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at`, [this.instanceKey, name, after[name], now]);
109
+ }
110
+ }
111
+ return after;
112
+ }
113
+ async close() {
114
+ if (this.ownsPool)
115
+ await this.pool.end();
116
+ }
117
+ ensureReady() {
118
+ if (!this.ready) {
119
+ this.ready = this.migrate().catch((error) => {
120
+ this.ready = undefined;
121
+ throw error;
122
+ });
123
+ }
124
+ return this.ready;
125
+ }
126
+ async migrate() {
127
+ await this.pool.query(EMOTIONS_SCHEMA);
128
+ for (const def of this.defs) {
129
+ await this.pool.query(`INSERT INTO emocentric_emotions (instance_key, emotion, value)
130
+ VALUES ($1, $2, $3)
131
+ ON CONFLICT (instance_key, emotion) DO NOTHING`, [this.instanceKey, def.name, def.baseline]);
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,5 @@
1
+ export { PostgresMemoryStore, type PostgresMemoryStoreOptions, type ReembedAllOptions, } from "./store.js";
2
+ export { PostgresEmotionalState, type PostgresEmotionalStateOptions, } from "./emotional-state.js";
3
+ export { PostgresShortTermMemory, type PostgresShortTermMemoryOptions, } from "./short-term.js";
4
+ export { claimInstance, deleteInstance, listInstances, type InstanceRecord, } from "./instances.js";
5
+ export { createAgentInstance, clearInstanceRegistry, forgetInstance, forgetBlueprint, type AgentInstance, type CreateAgentInstanceOptions, } from "./create-instance.js";
@@ -0,0 +1,5 @@
1
+ export { PostgresMemoryStore, } from "./store.js";
2
+ export { PostgresEmotionalState, } from "./emotional-state.js";
3
+ export { PostgresShortTermMemory, } from "./short-term.js";
4
+ export { claimInstance, deleteInstance, listInstances, } from "./instances.js";
5
+ export { createAgentInstance, clearInstanceRegistry, forgetInstance, forgetBlueprint, } from "./create-instance.js";
@@ -0,0 +1,32 @@
1
+ import pg from "pg";
2
+ import { type InstanceScope } from "../blueprint/scope.js";
3
+ /** One row of the instance directory: a living instance and its origin. */
4
+ export interface InstanceRecord {
5
+ instanceKey: string;
6
+ blueprintId: string;
7
+ userId: string;
8
+ sessionId?: string;
9
+ /** The blueprint version this instance was born from. */
10
+ blueprintVersion?: number;
11
+ /** ISO 8601. */
12
+ createdAt: string;
13
+ }
14
+ export declare function ensureInstancesTable(pool: pg.Pool): Promise<void>;
15
+ /**
16
+ * Atomically claim a scope's instance row. Returns true when this call
17
+ * created it — the caller then owns seeding (emotion baselines, seed facts).
18
+ * False means the instance already exists and must not be re-seeded.
19
+ */
20
+ export declare function claimInstance(pool: pg.Pool, scope: InstanceScope, blueprintVersion?: number): Promise<boolean>;
21
+ /** Undo a claim whose seeding failed, so the next attempt re-seeds. */
22
+ export declare function releaseInstance(pool: pg.Pool, scope: InstanceScope): Promise<void>;
23
+ /** Enumerate instances, optionally narrowed to one blueprint and/or user. */
24
+ export declare function listInstances(pool: pg.Pool, filter?: {
25
+ blueprintId?: string;
26
+ userId?: string;
27
+ }): Promise<InstanceRecord[]>;
28
+ /**
29
+ * Erase one instance entirely — its memories, its feelings, and its
30
+ * directory row. Returns true when the instance existed.
31
+ */
32
+ export declare function deleteInstance(pool: pg.Pool, scope: InstanceScope): Promise<boolean>;
@@ -0,0 +1,77 @@
1
+ import { instanceKey } from "../blueprint/scope.js";
2
+ import { INSTANCES_SCHEMA } from "./schema.js";
3
+ export async function ensureInstancesTable(pool) {
4
+ await pool.query(INSTANCES_SCHEMA);
5
+ }
6
+ /**
7
+ * Atomically claim a scope's instance row. Returns true when this call
8
+ * created it — the caller then owns seeding (emotion baselines, seed facts).
9
+ * False means the instance already exists and must not be re-seeded.
10
+ */
11
+ export async function claimInstance(pool, scope, blueprintVersion) {
12
+ await ensureInstancesTable(pool);
13
+ const result = await pool.query(`INSERT INTO emocentric_instances
14
+ (instance_key, blueprint_id, user_id, session_id, blueprint_version)
15
+ VALUES ($1, $2, $3, $4, $5)
16
+ ON CONFLICT (instance_key) DO NOTHING`, [
17
+ instanceKey(scope),
18
+ scope.blueprintId,
19
+ scope.userId,
20
+ scope.sessionId ?? null,
21
+ blueprintVersion ?? null,
22
+ ]);
23
+ return (result.rowCount ?? 0) > 0;
24
+ }
25
+ /** Undo a claim whose seeding failed, so the next attempt re-seeds. */
26
+ export async function releaseInstance(pool, scope) {
27
+ await pool.query(`DELETE FROM emocentric_instances WHERE instance_key = $1`, [
28
+ instanceKey(scope),
29
+ ]);
30
+ }
31
+ /** Enumerate instances, optionally narrowed to one blueprint and/or user. */
32
+ export async function listInstances(pool, filter = {}) {
33
+ await ensureInstancesTable(pool);
34
+ const conditions = [];
35
+ const params = [];
36
+ if (filter.blueprintId !== undefined) {
37
+ params.push(filter.blueprintId);
38
+ conditions.push(`blueprint_id = $${params.length}`);
39
+ }
40
+ if (filter.userId !== undefined) {
41
+ params.push(filter.userId);
42
+ conditions.push(`user_id = $${params.length}`);
43
+ }
44
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
45
+ const { rows } = await pool.query(`SELECT instance_key, blueprint_id, user_id, session_id, blueprint_version, created_at
46
+ FROM emocentric_instances ${where} ORDER BY created_at`, params);
47
+ return rows.map((row) => ({
48
+ instanceKey: row.instance_key,
49
+ blueprintId: row.blueprint_id,
50
+ userId: row.user_id,
51
+ ...(row.session_id !== null && { sessionId: row.session_id }),
52
+ ...(row.blueprint_version !== null && {
53
+ blueprintVersion: row.blueprint_version,
54
+ }),
55
+ createdAt: row.created_at.toISOString(),
56
+ }));
57
+ }
58
+ /**
59
+ * Erase one instance entirely — its memories, its feelings, and its
60
+ * directory row. Returns true when the instance existed.
61
+ */
62
+ export async function deleteInstance(pool, scope) {
63
+ const key = instanceKey(scope);
64
+ await ensureInstancesTable(pool);
65
+ await pool.query(`DELETE FROM emocentric_facts WHERE instance_key = $1`, [key])
66
+ .catch(swallowMissingTable);
67
+ await pool.query(`DELETE FROM emocentric_emotions WHERE instance_key = $1`, [key])
68
+ .catch(swallowMissingTable);
69
+ const result = await pool.query(`DELETE FROM emocentric_instances WHERE instance_key = $1`, [key]);
70
+ return (result.rowCount ?? 0) > 0;
71
+ }
72
+ /** 42P01 = undefined_table: an instance can predate first use of a store. */
73
+ function swallowMissingTable(error) {
74
+ if (error.code === "42P01")
75
+ return { rowCount: 0 };
76
+ throw error;
77
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Idempotent schema, applied automatically on first use (and safe to run on
3
+ * every startup). No migration framework — plain CREATE IF NOT EXISTS.
4
+ *
5
+ * All state is keyed by `instance_key` — the canonical string for one
6
+ * (blueprint, user[, session]) scope, see src/blueprint/scope.ts. The
7
+ * decomposition lives once, in `emocentric_instances`; facts and emotions
8
+ * carry only the key.
9
+ */
10
+ export declare const VECTOR_EXTENSION = "CREATE EXTENSION IF NOT EXISTS vector;";
11
+ export declare const CORE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS emocentric_facts (\n id UUID PRIMARY KEY,\n instance_key TEXT NOT NULL,\n kind TEXT NOT NULL,\n content TEXT NOT NULL,\n subject_id TEXT,\n importance REAL NOT NULL DEFAULT 0.5,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_accessed_at TIMESTAMPTZ,\n superseded_by UUID,\n metadata JSONB NOT NULL DEFAULT '{}'::jsonb\n);\n\nCREATE INDEX IF NOT EXISTS emocentric_facts_instance_kind_idx\n ON emocentric_facts (instance_key, kind);\n\nCREATE INDEX IF NOT EXISTS emocentric_facts_instance_subject_idx\n ON emocentric_facts (instance_key, subject_id);\n";
12
+ /** One row per slider per instance. `updated_at` drives the time-decay
13
+ * stage (it runs at event arrival, before the interpreter/recall loop). */
14
+ export declare const EMOTIONS_SCHEMA = "\nCREATE TABLE IF NOT EXISTS emocentric_emotions (\n instance_key TEXT NOT NULL,\n emotion TEXT NOT NULL,\n value REAL NOT NULL,\n updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n PRIMARY KEY (instance_key, emotion)\n);\n";
15
+ /** One row per living instance. A row means "this instance exists and was
16
+ * seeded from its blueprint" — it is created (with the seed facts) on a
17
+ * user's first contact and doubles as the instance directory: enumerate a
18
+ * blueprint's instances, find everything a user owns, delete it all. */
19
+ export declare const INSTANCES_SCHEMA = "\nCREATE TABLE IF NOT EXISTS emocentric_instances (\n instance_key TEXT PRIMARY KEY,\n blueprint_id TEXT NOT NULL,\n user_id TEXT NOT NULL,\n session_id TEXT,\n blueprint_version INTEGER,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS emocentric_instances_blueprint_idx\n ON emocentric_instances (blueprint_id);\n\nCREATE INDEX IF NOT EXISTS emocentric_instances_user_idx\n ON emocentric_instances (user_id);\n";
20
+ /** The short-term window: recent engagements per instance, capacity-trimmed
21
+ * (never time-expired). `id` is the insertion order used for recency. */
22
+ export declare const SHORT_TERM_SCHEMA = "\nCREATE TABLE IF NOT EXISTS emocentric_short_term (\n id BIGSERIAL PRIMARY KEY,\n instance_key TEXT NOT NULL,\n occurred_at TIMESTAMPTZ NOT NULL,\n actor_id TEXT NOT NULL,\n actor_name TEXT,\n perceived TEXT NOT NULL,\n responded TEXT NOT NULL DEFAULT '',\n created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS emocentric_short_term_instance_idx\n ON emocentric_short_term (instance_key, id DESC);\n";
23
+ export declare function addEmbeddingColumn(dimensions: number): string;
24
+ export declare const DROP_EMBEDDING = "\nDROP INDEX IF EXISTS emocentric_facts_embedding_idx;\nALTER TABLE emocentric_facts DROP COLUMN IF EXISTS embedding;\n";
25
+ /**
26
+ * Fixed key for the pg advisory lock that serializes a destructive,
27
+ * table-wide embedding-column rebuild (`reembedAll`) against concurrent
28
+ * rebuilds. Arbitrary but stable; scoped to this database. */
29
+ export declare const REEMBED_ADVISORY_LOCK = 2026061500;
30
+ /** Returns e.g. "vector(256)" for the current embedding column, or null. */
31
+ export declare const EMBEDDING_COLUMN_TYPE = "\nSELECT format_type(a.atttypid, a.atttypmod) AS column_type\nFROM pg_attribute a\nWHERE a.attrelid = 'emocentric_facts'::regclass\n AND a.attname = 'embedding'\n AND NOT a.attisdropped;\n";
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Idempotent schema, applied automatically on first use (and safe to run on
3
+ * every startup). No migration framework — plain CREATE IF NOT EXISTS.
4
+ *
5
+ * All state is keyed by `instance_key` — the canonical string for one
6
+ * (blueprint, user[, session]) scope, see src/blueprint/scope.ts. The
7
+ * decomposition lives once, in `emocentric_instances`; facts and emotions
8
+ * carry only the key.
9
+ */
10
+ export const VECTOR_EXTENSION = `CREATE EXTENSION IF NOT EXISTS vector;`;
11
+ export const CORE_SCHEMA = `
12
+ CREATE TABLE IF NOT EXISTS emocentric_facts (
13
+ id UUID PRIMARY KEY,
14
+ instance_key TEXT NOT NULL,
15
+ kind TEXT NOT NULL,
16
+ content TEXT NOT NULL,
17
+ subject_id TEXT,
18
+ importance REAL NOT NULL DEFAULT 0.5,
19
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
20
+ last_accessed_at TIMESTAMPTZ,
21
+ superseded_by UUID,
22
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS emocentric_facts_instance_kind_idx
26
+ ON emocentric_facts (instance_key, kind);
27
+
28
+ CREATE INDEX IF NOT EXISTS emocentric_facts_instance_subject_idx
29
+ ON emocentric_facts (instance_key, subject_id);
30
+ `;
31
+ /** One row per slider per instance. `updated_at` drives the time-decay
32
+ * stage (it runs at event arrival, before the interpreter/recall loop). */
33
+ export const EMOTIONS_SCHEMA = `
34
+ CREATE TABLE IF NOT EXISTS emocentric_emotions (
35
+ instance_key TEXT NOT NULL,
36
+ emotion TEXT NOT NULL,
37
+ value REAL NOT NULL,
38
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
39
+ PRIMARY KEY (instance_key, emotion)
40
+ );
41
+ `;
42
+ /** One row per living instance. A row means "this instance exists and was
43
+ * seeded from its blueprint" — it is created (with the seed facts) on a
44
+ * user's first contact and doubles as the instance directory: enumerate a
45
+ * blueprint's instances, find everything a user owns, delete it all. */
46
+ export const INSTANCES_SCHEMA = `
47
+ CREATE TABLE IF NOT EXISTS emocentric_instances (
48
+ instance_key TEXT PRIMARY KEY,
49
+ blueprint_id TEXT NOT NULL,
50
+ user_id TEXT NOT NULL,
51
+ session_id TEXT,
52
+ blueprint_version INTEGER,
53
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS emocentric_instances_blueprint_idx
57
+ ON emocentric_instances (blueprint_id);
58
+
59
+ CREATE INDEX IF NOT EXISTS emocentric_instances_user_idx
60
+ ON emocentric_instances (user_id);
61
+ `;
62
+ /** The short-term window: recent engagements per instance, capacity-trimmed
63
+ * (never time-expired). `id` is the insertion order used for recency. */
64
+ export const SHORT_TERM_SCHEMA = `
65
+ CREATE TABLE IF NOT EXISTS emocentric_short_term (
66
+ id BIGSERIAL PRIMARY KEY,
67
+ instance_key TEXT NOT NULL,
68
+ occurred_at TIMESTAMPTZ NOT NULL,
69
+ actor_id TEXT NOT NULL,
70
+ actor_name TEXT,
71
+ perceived TEXT NOT NULL,
72
+ responded TEXT NOT NULL DEFAULT '',
73
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS emocentric_short_term_instance_idx
77
+ ON emocentric_short_term (instance_key, id DESC);
78
+ `;
79
+ export function addEmbeddingColumn(dimensions) {
80
+ return `ALTER TABLE emocentric_facts ADD COLUMN IF NOT EXISTS embedding vector(${dimensions});`;
81
+ }
82
+ export const DROP_EMBEDDING = `
83
+ DROP INDEX IF EXISTS emocentric_facts_embedding_idx;
84
+ ALTER TABLE emocentric_facts DROP COLUMN IF EXISTS embedding;
85
+ `;
86
+ /**
87
+ * Fixed key for the pg advisory lock that serializes a destructive,
88
+ * table-wide embedding-column rebuild (`reembedAll`) against concurrent
89
+ * rebuilds. Arbitrary but stable; scoped to this database. */
90
+ export const REEMBED_ADVISORY_LOCK = 2_026_061_500;
91
+ /** Returns e.g. "vector(256)" for the current embedding column, or null. */
92
+ export const EMBEDDING_COLUMN_TYPE = `
93
+ SELECT format_type(a.atttypid, a.atttypmod) AS column_type
94
+ FROM pg_attribute a
95
+ WHERE a.attrelid = 'emocentric_facts'::regclass
96
+ AND a.attname = 'embedding'
97
+ AND NOT a.attisdropped;
98
+ `;
@@ -0,0 +1,32 @@
1
+ import pg from "pg";
2
+ import { type InstanceScope } from "../blueprint/scope.js";
3
+ import { type Engagement, type ShortTermMemory } from "../short-term/short-term.js";
4
+ export interface PostgresShortTermMemoryOptions {
5
+ /** e.g. process.env.DATABASE_URL. Either this or `pool` is required. */
6
+ connectionString?: string;
7
+ /** Bring your own pg Pool (the store will not close it). */
8
+ pool?: pg.Pool;
9
+ ssl?: pg.PoolConfig["ssl"];
10
+ /** Which instance's window this is — every query is filtered by its key. */
11
+ scope: InstanceScope;
12
+ /** Max engagements kept; older ones are trimmed on append (never expired by
13
+ * time). Default {@link DEFAULT_STM_CAPACITY}. */
14
+ capacity?: number;
15
+ }
16
+ /**
17
+ * Persistent short-term memory: one Postgres row per engagement per instance,
18
+ * so the window survives restarts and reopening the chat. Capacity-bounded
19
+ * (the last N), never time-cleared.
20
+ */
21
+ export declare class PostgresShortTermMemory implements ShortTermMemory {
22
+ private readonly pool;
23
+ private readonly ownsPool;
24
+ private readonly instanceKey;
25
+ private readonly capacity;
26
+ private ready;
27
+ constructor(options: PostgresShortTermMemoryOptions);
28
+ recent(limit?: number): Promise<Engagement[]>;
29
+ append(engagement: Engagement): Promise<void>;
30
+ close(): Promise<void>;
31
+ private ensureReady;
32
+ }