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,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
|
+
}
|