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,95 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
import { instanceKey } from "../blueprint/scope.js";
|
|
3
|
+
import { DEFAULT_STM_CAPACITY, } from "../short-term/short-term.js";
|
|
4
|
+
import { SHORT_TERM_SCHEMA } from "./schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* Persistent short-term memory: one Postgres row per engagement per instance,
|
|
7
|
+
* so the window survives restarts and reopening the chat. Capacity-bounded
|
|
8
|
+
* (the last N), never time-cleared.
|
|
9
|
+
*/
|
|
10
|
+
export class PostgresShortTermMemory {
|
|
11
|
+
pool;
|
|
12
|
+
ownsPool;
|
|
13
|
+
instanceKey;
|
|
14
|
+
capacity;
|
|
15
|
+
ready;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
if (options.pool) {
|
|
18
|
+
this.pool = options.pool;
|
|
19
|
+
this.ownsPool = false;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const connectionString = options.connectionString ?? process.env.DATABASE_URL;
|
|
23
|
+
if (!connectionString) {
|
|
24
|
+
throw new Error("PostgresShortTermMemory needs a `connectionString` (or DATABASE_URL env var) or an existing `pool`.");
|
|
25
|
+
}
|
|
26
|
+
this.pool = new pg.Pool({
|
|
27
|
+
connectionString,
|
|
28
|
+
...(options.ssl !== undefined && { ssl: options.ssl }),
|
|
29
|
+
});
|
|
30
|
+
this.ownsPool = true;
|
|
31
|
+
}
|
|
32
|
+
this.instanceKey = instanceKey(options.scope);
|
|
33
|
+
this.capacity = Math.max(1, options.capacity ?? DEFAULT_STM_CAPACITY);
|
|
34
|
+
}
|
|
35
|
+
async recent(limit) {
|
|
36
|
+
await this.ensureReady();
|
|
37
|
+
const n = Math.max(0, limit ?? this.capacity);
|
|
38
|
+
if (n === 0)
|
|
39
|
+
return [];
|
|
40
|
+
const { rows } = await this.pool.query(`SELECT occurred_at, actor_id, actor_name, perceived, responded
|
|
41
|
+
FROM emocentric_short_term
|
|
42
|
+
WHERE instance_key = $1
|
|
43
|
+
ORDER BY id DESC
|
|
44
|
+
LIMIT $2`, [this.instanceKey, n]);
|
|
45
|
+
// SQL returns newest-first; flip to oldest-first for the window.
|
|
46
|
+
return rows.reverse().map((r) => ({
|
|
47
|
+
occurredAt: r.occurred_at.toISOString(),
|
|
48
|
+
actor: r.actor_name !== null
|
|
49
|
+
? { id: r.actor_id, displayName: r.actor_name }
|
|
50
|
+
: { id: r.actor_id },
|
|
51
|
+
perceived: r.perceived,
|
|
52
|
+
responded: r.responded,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
async append(engagement) {
|
|
56
|
+
await this.ensureReady();
|
|
57
|
+
await this.pool.query(`INSERT INTO emocentric_short_term
|
|
58
|
+
(instance_key, occurred_at, actor_id, actor_name, perceived, responded)
|
|
59
|
+
VALUES ($1, $2, $3, $4, $5, $6)`, [
|
|
60
|
+
this.instanceKey,
|
|
61
|
+
engagement.occurredAt,
|
|
62
|
+
engagement.actor.id,
|
|
63
|
+
engagement.actor.displayName ?? null,
|
|
64
|
+
engagement.perceived,
|
|
65
|
+
engagement.responded,
|
|
66
|
+
]);
|
|
67
|
+
// Capacity trim: drop everything older than the newest `capacity` rows.
|
|
68
|
+
await this.pool.query(`DELETE FROM emocentric_short_term
|
|
69
|
+
WHERE instance_key = $1
|
|
70
|
+
AND id < (
|
|
71
|
+
SELECT min(id) FROM (
|
|
72
|
+
SELECT id FROM emocentric_short_term
|
|
73
|
+
WHERE instance_key = $1
|
|
74
|
+
ORDER BY id DESC
|
|
75
|
+
LIMIT $2
|
|
76
|
+
) keep
|
|
77
|
+
)`, [this.instanceKey, this.capacity]);
|
|
78
|
+
}
|
|
79
|
+
async close() {
|
|
80
|
+
if (this.ownsPool)
|
|
81
|
+
await this.pool.end();
|
|
82
|
+
}
|
|
83
|
+
ensureReady() {
|
|
84
|
+
if (!this.ready) {
|
|
85
|
+
this.ready = this.pool
|
|
86
|
+
.query(SHORT_TERM_SCHEMA)
|
|
87
|
+
.then(() => undefined)
|
|
88
|
+
.catch((error) => {
|
|
89
|
+
this.ready = undefined;
|
|
90
|
+
throw error;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return this.ready;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
import type { Fact, FactInput, FactPatch } from "../memory/fact.js";
|
|
3
|
+
import type { Embedder, MemoryQuery, MemoryStore, ScoredFact } from "../memory/store.js";
|
|
4
|
+
import { type ScoringWeights } from "../memory/scoring.js";
|
|
5
|
+
import { type InstanceScope } from "../blueprint/scope.js";
|
|
6
|
+
export interface PostgresMemoryStoreOptions {
|
|
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
|
+
/** Pass `{ rejectUnauthorized: false }` when connecting to Railway's
|
|
12
|
+
* public proxy; leave unset for localhost / private networking. */
|
|
13
|
+
ssl?: pg.PoolConfig["ssl"];
|
|
14
|
+
/** Which instance's memory this store handle is scoped to. One database
|
|
15
|
+
* serves many instances; every query is filtered by this scope's key. */
|
|
16
|
+
scope: InstanceScope;
|
|
17
|
+
/** Defaults to {@link OpenAIEmbedder} (needs OPENAI_API_KEY). Pass any other
|
|
18
|
+
* Embedder to override; run reembedAll({ confirm: true }) after switching
|
|
19
|
+
* dimensions. */
|
|
20
|
+
embedder?: Embedder;
|
|
21
|
+
weights?: Partial<ScoringWeights>;
|
|
22
|
+
/** Cosine-relevance floor for text queries — results below it are dropped as
|
|
23
|
+
* "no real match". Overrides the embedder's own default; a per-query
|
|
24
|
+
* `minRelevance` overrides this. Omit to use the embedder's default. */
|
|
25
|
+
minRelevance?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface ReembedAllOptions {
|
|
28
|
+
/** Required acknowledgement. `reembedAll` rebuilds the SHARED embedding
|
|
29
|
+
* column for every instance in the table, not just this store's scope —
|
|
30
|
+
* pass `true` only when you intend a full table-wide re-embed. */
|
|
31
|
+
confirm: boolean;
|
|
32
|
+
/** Rows re-embedded per batch. Default 100. */
|
|
33
|
+
batchSize?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The SDK's production MemoryStore: Postgres + pgvector.
|
|
37
|
+
*
|
|
38
|
+
* - `add` embeds each fact's content and stores row + vector
|
|
39
|
+
* - `fetch` embeds the query, scans this instance's facts exactly, then blends
|
|
40
|
+
* relevance/importance/recency and returns the top N
|
|
41
|
+
* - schema is created automatically on first use (`migrate()` is also
|
|
42
|
+
* public and idempotent if you prefer to run it explicitly)
|
|
43
|
+
*
|
|
44
|
+
* Works against any Postgres with the pgvector extension — the bundled
|
|
45
|
+
* docker-compose locally, Railway's PostgreSQL service in production.
|
|
46
|
+
*/
|
|
47
|
+
export declare class PostgresMemoryStore implements MemoryStore {
|
|
48
|
+
private readonly pool;
|
|
49
|
+
private readonly ownsPool;
|
|
50
|
+
private readonly instanceKey;
|
|
51
|
+
private readonly embedder;
|
|
52
|
+
private readonly weights;
|
|
53
|
+
private readonly minRelevance;
|
|
54
|
+
private ready;
|
|
55
|
+
constructor(options: PostgresMemoryStoreOptions);
|
|
56
|
+
/**
|
|
57
|
+
* Create the schema if needed and verify the embedding column matches the
|
|
58
|
+
* configured embedder's dimensions. Idempotent; called lazily by every
|
|
59
|
+
* other method, so you only need it explicitly for startup health checks.
|
|
60
|
+
*/
|
|
61
|
+
migrate(): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Rebuild the embedding column and re-embed every fact with the currently
|
|
64
|
+
* configured embedder. Run once after switching to a different-dimension
|
|
65
|
+
* embedder.
|
|
66
|
+
*
|
|
67
|
+
* DESTRUCTIVE and TABLE-WIDE: this drops and recreates the shared `embedding`
|
|
68
|
+
* column for EVERY instance in `emocentric_facts`, not just this store's
|
|
69
|
+
* scope — pass `{ confirm: true }` to acknowledge that. The whole rebuild
|
|
70
|
+
* runs in one transaction under a pg advisory lock, so it's atomic (a
|
|
71
|
+
* failure rolls back to the prior column rather than leaving a half-NULL
|
|
72
|
+
* one) and serialized against concurrent rebuilds. The embedder calls happen
|
|
73
|
+
* inside that transaction, so for a very large table this holds an exclusive
|
|
74
|
+
* lock for the duration — treat it as a maintenance operation.
|
|
75
|
+
*/
|
|
76
|
+
reembedAll(options: ReembedAllOptions): Promise<number>;
|
|
77
|
+
add(inputs: FactInput[]): Promise<Fact[]>;
|
|
78
|
+
fetch(query: MemoryQuery): Promise<ScoredFact[]>;
|
|
79
|
+
get(id: string): Promise<Fact | null>;
|
|
80
|
+
update(id: string, patch: FactPatch): Promise<Fact | null>;
|
|
81
|
+
delete(id: string): Promise<boolean>;
|
|
82
|
+
close(): Promise<void>;
|
|
83
|
+
private ensureReady;
|
|
84
|
+
private embeddingColumnDimensions;
|
|
85
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
import { generateEventId } from "../events/context-event.js";
|
|
3
|
+
import { DEFAULT_SCORING_WEIGHTS, blendScore, clamp01, } from "../memory/scoring.js";
|
|
4
|
+
import { OpenAIEmbedder } from "../memory/openai-embedder.js";
|
|
5
|
+
import { instanceKey } from "../blueprint/scope.js";
|
|
6
|
+
import { CORE_SCHEMA, DROP_EMBEDDING, EMBEDDING_COLUMN_TYPE, REEMBED_ADVISORY_LOCK, VECTOR_EXTENSION, addEmbeddingColumn, } from "./schema.js";
|
|
7
|
+
/** Candidate pool for the no-text "what's on my mind" branch (re-ranked by the
|
|
8
|
+
* blend). Text queries don't use this — they return all facts above the floor. */
|
|
9
|
+
const CANDIDATE_POOL = 25;
|
|
10
|
+
/** Default cap for the no-text browse mode, which has no relevance floor to
|
|
11
|
+
* bound it. Text queries are bounded by the floor instead, not a fixed K. */
|
|
12
|
+
const DEFAULT_BROWSE_LIMIT = 10;
|
|
13
|
+
/**
|
|
14
|
+
* The SDK's production MemoryStore: Postgres + pgvector.
|
|
15
|
+
*
|
|
16
|
+
* - `add` embeds each fact's content and stores row + vector
|
|
17
|
+
* - `fetch` embeds the query, scans this instance's facts exactly, then blends
|
|
18
|
+
* relevance/importance/recency and returns the top N
|
|
19
|
+
* - schema is created automatically on first use (`migrate()` is also
|
|
20
|
+
* public and idempotent if you prefer to run it explicitly)
|
|
21
|
+
*
|
|
22
|
+
* Works against any Postgres with the pgvector extension — the bundled
|
|
23
|
+
* docker-compose locally, Railway's PostgreSQL service in production.
|
|
24
|
+
*/
|
|
25
|
+
export class PostgresMemoryStore {
|
|
26
|
+
pool;
|
|
27
|
+
ownsPool;
|
|
28
|
+
instanceKey;
|
|
29
|
+
embedder;
|
|
30
|
+
weights;
|
|
31
|
+
minRelevance;
|
|
32
|
+
ready;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
if (options.pool) {
|
|
35
|
+
this.pool = options.pool;
|
|
36
|
+
this.ownsPool = false;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const connectionString = options.connectionString ?? process.env.DATABASE_URL;
|
|
40
|
+
if (!connectionString) {
|
|
41
|
+
throw new Error("PostgresMemoryStore needs a `connectionString` (or DATABASE_URL env var) or an existing `pool`.");
|
|
42
|
+
}
|
|
43
|
+
this.pool = new pg.Pool({
|
|
44
|
+
connectionString,
|
|
45
|
+
...(options.ssl !== undefined && { ssl: options.ssl }),
|
|
46
|
+
});
|
|
47
|
+
this.ownsPool = true;
|
|
48
|
+
}
|
|
49
|
+
this.instanceKey = instanceKey(options.scope);
|
|
50
|
+
this.embedder = options.embedder ?? new OpenAIEmbedder();
|
|
51
|
+
this.weights = { ...DEFAULT_SCORING_WEIGHTS, ...options.weights };
|
|
52
|
+
this.minRelevance = options.minRelevance;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create the schema if needed and verify the embedding column matches the
|
|
56
|
+
* configured embedder's dimensions. Idempotent; called lazily by every
|
|
57
|
+
* other method, so you only need it explicitly for startup health checks.
|
|
58
|
+
*/
|
|
59
|
+
async migrate() {
|
|
60
|
+
try {
|
|
61
|
+
await this.pool.query(VECTOR_EXTENSION);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error("Could not enable the pgvector extension. Use a Postgres with pgvector " +
|
|
65
|
+
"(locally: the bundled docker-compose image; on Railway: the standard " +
|
|
66
|
+
`PostgreSQL service). Underlying error: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
await this.pool.query(CORE_SCHEMA);
|
|
69
|
+
const existing = await this.embeddingColumnDimensions();
|
|
70
|
+
if (existing === null) {
|
|
71
|
+
await this.pool.query(addEmbeddingColumn(this.embedder.dimensions));
|
|
72
|
+
}
|
|
73
|
+
else if (existing !== this.embedder.dimensions) {
|
|
74
|
+
throw new Error(`Embedding column is vector(${existing}) but the configured embedder ` +
|
|
75
|
+
`produces ${this.embedder.dimensions} dimensions. You probably ` +
|
|
76
|
+
"switched embedders — call reembedAll({ confirm: true }) once to " +
|
|
77
|
+
"migrate stored vectors.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Rebuild the embedding column and re-embed every fact with the currently
|
|
82
|
+
* configured embedder. Run once after switching to a different-dimension
|
|
83
|
+
* embedder.
|
|
84
|
+
*
|
|
85
|
+
* DESTRUCTIVE and TABLE-WIDE: this drops and recreates the shared `embedding`
|
|
86
|
+
* column for EVERY instance in `emocentric_facts`, not just this store's
|
|
87
|
+
* scope — pass `{ confirm: true }` to acknowledge that. The whole rebuild
|
|
88
|
+
* runs in one transaction under a pg advisory lock, so it's atomic (a
|
|
89
|
+
* failure rolls back to the prior column rather than leaving a half-NULL
|
|
90
|
+
* one) and serialized against concurrent rebuilds. The embedder calls happen
|
|
91
|
+
* inside that transaction, so for a very large table this holds an exclusive
|
|
92
|
+
* lock for the duration — treat it as a maintenance operation.
|
|
93
|
+
*/
|
|
94
|
+
async reembedAll(options) {
|
|
95
|
+
if (!options?.confirm) {
|
|
96
|
+
throw new Error("reembedAll() is destructive and table-wide: it drops and rebuilds the " +
|
|
97
|
+
"shared `embedding` column for EVERY instance in emocentric_facts and " +
|
|
98
|
+
"re-embeds all rows. It is NOT scoped to this store. Pass " +
|
|
99
|
+
"{ confirm: true } to proceed.");
|
|
100
|
+
}
|
|
101
|
+
const batchSize = options.batchSize ?? 100;
|
|
102
|
+
const client = await this.pool.connect();
|
|
103
|
+
try {
|
|
104
|
+
await client.query("BEGIN");
|
|
105
|
+
// Serialize against any concurrent rebuild; auto-released on COMMIT/ROLLBACK.
|
|
106
|
+
await client.query("SELECT pg_advisory_xact_lock($1)", [
|
|
107
|
+
REEMBED_ADVISORY_LOCK,
|
|
108
|
+
]);
|
|
109
|
+
await client.query(VECTOR_EXTENSION);
|
|
110
|
+
await client.query(CORE_SCHEMA);
|
|
111
|
+
await client.query(DROP_EMBEDDING);
|
|
112
|
+
await client.query(addEmbeddingColumn(this.embedder.dimensions));
|
|
113
|
+
let total = 0;
|
|
114
|
+
for (;;) {
|
|
115
|
+
const { rows } = await client.query(`SELECT id, content FROM emocentric_facts
|
|
116
|
+
WHERE embedding IS NULL ORDER BY created_at LIMIT $1`, [batchSize]);
|
|
117
|
+
if (rows.length === 0)
|
|
118
|
+
break;
|
|
119
|
+
const vectors = await this.embedder.embed(rows.map((r) => r.content));
|
|
120
|
+
if (vectors.length !== rows.length) {
|
|
121
|
+
throw new Error(`Embedder returned ${vectors.length} vectors for ${rows.length} inputs.`);
|
|
122
|
+
}
|
|
123
|
+
for (let i = 0; i < rows.length; i++) {
|
|
124
|
+
await client.query(`UPDATE emocentric_facts SET embedding = $1::vector WHERE id = $2`, [toVectorLiteral(vectors[i]), rows[i].id]);
|
|
125
|
+
}
|
|
126
|
+
total += rows.length;
|
|
127
|
+
}
|
|
128
|
+
await client.query("COMMIT");
|
|
129
|
+
this.ready = undefined; // re-run dimension check on next use
|
|
130
|
+
return total;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
await client.query("ROLLBACK").catch(() => { });
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
client.release();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async add(inputs) {
|
|
141
|
+
await this.ensureReady();
|
|
142
|
+
if (inputs.length === 0)
|
|
143
|
+
return [];
|
|
144
|
+
const vectors = await this.embedder.embed(inputs.map((i) => i.content));
|
|
145
|
+
const facts = [];
|
|
146
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
147
|
+
const input = inputs[i];
|
|
148
|
+
const fact = {
|
|
149
|
+
id: input.id ?? generateEventId(),
|
|
150
|
+
kind: input.kind,
|
|
151
|
+
content: input.content,
|
|
152
|
+
importance: input.importance ?? 0.5,
|
|
153
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
154
|
+
...(input.subjectId !== undefined && { subjectId: input.subjectId }),
|
|
155
|
+
...(input.metadata !== undefined && { metadata: input.metadata }),
|
|
156
|
+
};
|
|
157
|
+
await this.pool.query(`INSERT INTO emocentric_facts
|
|
158
|
+
(id, instance_key, kind, content, subject_id, importance, created_at, metadata, embedding)
|
|
159
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::vector)`, [
|
|
160
|
+
fact.id,
|
|
161
|
+
this.instanceKey,
|
|
162
|
+
fact.kind,
|
|
163
|
+
fact.content,
|
|
164
|
+
fact.subjectId ?? null,
|
|
165
|
+
fact.importance,
|
|
166
|
+
fact.createdAt,
|
|
167
|
+
JSON.stringify(fact.metadata ?? {}),
|
|
168
|
+
toVectorLiteral(vectors[i]),
|
|
169
|
+
]);
|
|
170
|
+
facts.push(fact);
|
|
171
|
+
}
|
|
172
|
+
return facts;
|
|
173
|
+
}
|
|
174
|
+
async fetch(query) {
|
|
175
|
+
await this.ensureReady();
|
|
176
|
+
const hasText = query.text.trim().length > 0;
|
|
177
|
+
// Relevance floor, resolved up front so a text query can push it into SQL:
|
|
178
|
+
// query override → store default → embedder's recommendation → 0. A semantic
|
|
179
|
+
// embedder returns a small positive cosine even for unrelated text, so the
|
|
180
|
+
// floor is what bounds the result set (in place of a fixed top-K).
|
|
181
|
+
const floor = query.minRelevance ?? this.minRelevance ?? this.embedder.minRelevance ?? 0;
|
|
182
|
+
const conditions = ["instance_key = $1"];
|
|
183
|
+
const params = [this.instanceKey];
|
|
184
|
+
if (!query.includeSuperseded)
|
|
185
|
+
conditions.push("superseded_by IS NULL");
|
|
186
|
+
if (query.kinds && query.kinds.length > 0) {
|
|
187
|
+
params.push(query.kinds);
|
|
188
|
+
conditions.push(`kind = ANY($${params.length})`);
|
|
189
|
+
}
|
|
190
|
+
if (query.subjectId !== undefined) {
|
|
191
|
+
params.push(query.subjectId);
|
|
192
|
+
conditions.push(`subject_id = $${params.length}`);
|
|
193
|
+
}
|
|
194
|
+
let rows;
|
|
195
|
+
if (hasText) {
|
|
196
|
+
const [queryVector] = await this.embedder.embed([query.text]);
|
|
197
|
+
params.push(toVectorLiteral(queryVector));
|
|
198
|
+
const vectorParam = `$${params.length}::vector`;
|
|
199
|
+
conditions.push("embedding IS NOT NULL");
|
|
200
|
+
// Exact per-instance scan returning EVERY fact above the relevance floor
|
|
201
|
+
// — no fixed top-K. The selective instance_key filter keeps the scan to
|
|
202
|
+
// this instance's rows; the floor is pushed into SQL so noise never
|
|
203
|
+
// crosses the wire. An explicit `limit` still caps it to the N nearest.
|
|
204
|
+
params.push(floor);
|
|
205
|
+
conditions.push(`1 - (embedding <=> ${vectorParam}) >= $${params.length}`);
|
|
206
|
+
let sql = `SELECT id, kind, content, subject_id, importance, created_at,
|
|
207
|
+
last_accessed_at, superseded_by, metadata,
|
|
208
|
+
1 - (embedding <=> ${vectorParam}) AS relevance
|
|
209
|
+
FROM emocentric_facts
|
|
210
|
+
WHERE ${conditions.join(" AND ")}
|
|
211
|
+
ORDER BY embedding <=> ${vectorParam}`;
|
|
212
|
+
if (query.limit !== undefined) {
|
|
213
|
+
params.push(query.limit);
|
|
214
|
+
sql += ` LIMIT $${params.length}`;
|
|
215
|
+
}
|
|
216
|
+
rows = (await this.pool.query(sql, params)).rows;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// No query text: rank purely by importance + recency. No relevance signal
|
|
220
|
+
// bounds the set, so this "what's on my mind" mode stays capped.
|
|
221
|
+
const browseLimit = query.limit ?? DEFAULT_BROWSE_LIMIT;
|
|
222
|
+
params.push(Math.max(CANDIDATE_POOL, browseLimit));
|
|
223
|
+
const result = await this.pool.query(`SELECT id, kind, content, subject_id, importance, created_at,
|
|
224
|
+
last_accessed_at, superseded_by, metadata,
|
|
225
|
+
0 AS relevance
|
|
226
|
+
FROM emocentric_facts
|
|
227
|
+
WHERE ${conditions.join(" AND ")}
|
|
228
|
+
ORDER BY importance DESC, created_at DESC
|
|
229
|
+
LIMIT $${params.length}`, params);
|
|
230
|
+
rows = result.rows;
|
|
231
|
+
}
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
const scored = [];
|
|
234
|
+
for (const row of rows) {
|
|
235
|
+
const relevance = clamp01(Number(row.relevance));
|
|
236
|
+
// Non-positive relevance is never a real match (the SQL floor already
|
|
237
|
+
// excludes sub-floor rows for text queries).
|
|
238
|
+
if (hasText && relevance <= 0)
|
|
239
|
+
continue;
|
|
240
|
+
const fact = rowToFact(row);
|
|
241
|
+
const score = blendScore(relevance, fact.importance, fact.createdAt, this.weights, now);
|
|
242
|
+
if (query.minScore !== undefined && score < query.minScore)
|
|
243
|
+
continue;
|
|
244
|
+
scored.push({ fact, score, relevance });
|
|
245
|
+
}
|
|
246
|
+
scored.sort((a, b) => b.score - a.score);
|
|
247
|
+
// Text queries return every fact above the floor (no fixed top-K) unless an
|
|
248
|
+
// explicit `limit` was given; the no-text browse mode stays bounded.
|
|
249
|
+
const top = hasText
|
|
250
|
+
? query.limit !== undefined
|
|
251
|
+
? scored.slice(0, query.limit)
|
|
252
|
+
: scored
|
|
253
|
+
: scored.slice(0, query.limit ?? DEFAULT_BROWSE_LIMIT);
|
|
254
|
+
if (top.length > 0) {
|
|
255
|
+
const accessedAt = new Date(now).toISOString();
|
|
256
|
+
await this.pool.query(`UPDATE emocentric_facts SET last_accessed_at = $1 WHERE id = ANY($2)`, [accessedAt, top.map((s) => s.fact.id)]);
|
|
257
|
+
for (const s of top)
|
|
258
|
+
s.fact.lastAccessedAt = accessedAt;
|
|
259
|
+
}
|
|
260
|
+
return top;
|
|
261
|
+
}
|
|
262
|
+
async get(id) {
|
|
263
|
+
await this.ensureReady();
|
|
264
|
+
const { rows } = await this.pool.query(`SELECT id, kind, content, subject_id, importance, created_at,
|
|
265
|
+
last_accessed_at, superseded_by, metadata
|
|
266
|
+
FROM emocentric_facts WHERE instance_key = $1 AND id = $2`, [this.instanceKey, id]);
|
|
267
|
+
return rows[0] ? rowToFact(rows[0]) : null;
|
|
268
|
+
}
|
|
269
|
+
async update(id, patch) {
|
|
270
|
+
await this.ensureReady();
|
|
271
|
+
const sets = [];
|
|
272
|
+
const params = [this.instanceKey, id];
|
|
273
|
+
const push = (fragment, value) => {
|
|
274
|
+
params.push(value);
|
|
275
|
+
sets.push(`${fragment} = $${params.length}`);
|
|
276
|
+
};
|
|
277
|
+
if (patch.kind !== undefined)
|
|
278
|
+
push("kind", patch.kind);
|
|
279
|
+
if (patch.content !== undefined)
|
|
280
|
+
push("content", patch.content);
|
|
281
|
+
if (patch.subjectId !== undefined)
|
|
282
|
+
push("subject_id", patch.subjectId);
|
|
283
|
+
if (patch.importance !== undefined)
|
|
284
|
+
push("importance", patch.importance);
|
|
285
|
+
if (patch.supersededBy !== undefined) {
|
|
286
|
+
push("superseded_by", patch.supersededBy);
|
|
287
|
+
}
|
|
288
|
+
if (patch.metadata !== undefined) {
|
|
289
|
+
push("metadata", JSON.stringify(patch.metadata));
|
|
290
|
+
}
|
|
291
|
+
if (patch.content !== undefined) {
|
|
292
|
+
const [vector] = await this.embedder.embed([patch.content]);
|
|
293
|
+
params.push(toVectorLiteral(vector));
|
|
294
|
+
sets.push(`embedding = $${params.length}::vector`);
|
|
295
|
+
}
|
|
296
|
+
if (sets.length === 0)
|
|
297
|
+
return this.get(id);
|
|
298
|
+
const { rows } = await this.pool.query(`UPDATE emocentric_facts SET ${sets.join(", ")}
|
|
299
|
+
WHERE instance_key = $1 AND id = $2
|
|
300
|
+
RETURNING id, kind, content, subject_id, importance, created_at,
|
|
301
|
+
last_accessed_at, superseded_by, metadata`, params);
|
|
302
|
+
return rows[0] ? rowToFact(rows[0]) : null;
|
|
303
|
+
}
|
|
304
|
+
async delete(id) {
|
|
305
|
+
await this.ensureReady();
|
|
306
|
+
const result = await this.pool.query(`DELETE FROM emocentric_facts WHERE instance_key = $1 AND id = $2`, [this.instanceKey, id]);
|
|
307
|
+
return (result.rowCount ?? 0) > 0;
|
|
308
|
+
}
|
|
309
|
+
async close() {
|
|
310
|
+
if (this.ownsPool)
|
|
311
|
+
await this.pool.end();
|
|
312
|
+
}
|
|
313
|
+
ensureReady() {
|
|
314
|
+
if (!this.ready) {
|
|
315
|
+
this.ready = this.migrate().catch((error) => {
|
|
316
|
+
this.ready = undefined; // allow retry after transient failures
|
|
317
|
+
throw error;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return this.ready;
|
|
321
|
+
}
|
|
322
|
+
async embeddingColumnDimensions() {
|
|
323
|
+
const { rows } = await this.pool.query(EMBEDDING_COLUMN_TYPE);
|
|
324
|
+
const match = rows[0]?.column_type.match(/^vector\((\d+)\)$/);
|
|
325
|
+
return match ? Number(match[1]) : null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function rowToFact(row) {
|
|
329
|
+
const metadata = row.metadata;
|
|
330
|
+
return {
|
|
331
|
+
id: row.id,
|
|
332
|
+
kind: row.kind,
|
|
333
|
+
content: row.content,
|
|
334
|
+
importance: row.importance,
|
|
335
|
+
createdAt: row.created_at.toISOString(),
|
|
336
|
+
...(row.subject_id !== null && { subjectId: row.subject_id }),
|
|
337
|
+
...(row.last_accessed_at !== null && {
|
|
338
|
+
lastAccessedAt: row.last_accessed_at.toISOString(),
|
|
339
|
+
}),
|
|
340
|
+
...(row.superseded_by !== null && { supersededBy: row.superseded_by }),
|
|
341
|
+
...(Object.keys(metadata).length > 0 && { metadata }),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** pgvector accepts vectors as '[0.1,0.2,...]' text literals. */
|
|
345
|
+
function toVectorLiteral(vector) {
|
|
346
|
+
return "[" + vector.join(",") + "]";
|
|
347
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DEFAULT_STM_CAPACITY, InMemoryShortTermMemory, relativeTime, renderEngagements, type Engagement, type InMemoryShortTermMemoryOptions, type ShortTermMemory, } from "./short-term.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DEFAULT_STM_CAPACITY, InMemoryShortTermMemory, relativeTime, renderEngagements, } from "./short-term.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Default number of engagements kept in the window. One engagement is one
|
|
2
|
+
* incoming event plus the agent's response to it. */
|
|
3
|
+
export declare const DEFAULT_STM_CAPACITY = 12;
|
|
4
|
+
/**
|
|
5
|
+
* One turn the agent recently lived through: what it perceived and how it
|
|
6
|
+
* responded, stamped with when it happened. The unit of short-term memory.
|
|
7
|
+
*/
|
|
8
|
+
export interface Engagement {
|
|
9
|
+
/** When the incoming event happened (the event's occurredAt). */
|
|
10
|
+
occurredAt: string;
|
|
11
|
+
/** Who the event was from. */
|
|
12
|
+
actor: {
|
|
13
|
+
id: string;
|
|
14
|
+
displayName?: string;
|
|
15
|
+
};
|
|
16
|
+
/** How the agent understood the incoming event — the interpreter's
|
|
17
|
+
* description. */
|
|
18
|
+
perceived: string;
|
|
19
|
+
/** What the agent did in response — its rendered output. Empty string when
|
|
20
|
+
* it stayed silent. */
|
|
21
|
+
responded: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The agent's working memory: a small, recency-ordered window of the most
|
|
25
|
+
* recent engagements with one instance, kept fresh so the agent always has
|
|
26
|
+
* the immediate thread in mind. Distinct from {@link MemoryStore} (durable,
|
|
27
|
+
* semantic, "relevant") — this is "recent". Per-instance; persisted so it
|
|
28
|
+
* survives across sessions. Two methods — swap the backend freely.
|
|
29
|
+
*/
|
|
30
|
+
export interface ShortTermMemory {
|
|
31
|
+
/** The most recent engagements, oldest-first (newest last). Up to `limit`,
|
|
32
|
+
* defaulting to the window's full capacity. */
|
|
33
|
+
recent(limit?: number): Promise<Engagement[]>;
|
|
34
|
+
/** Record one engagement, trimming the window to capacity. */
|
|
35
|
+
append(engagement: Engagement): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export interface InMemoryShortTermMemoryOptions {
|
|
38
|
+
/** Max engagements kept. Default {@link DEFAULT_STM_CAPACITY}. */
|
|
39
|
+
capacity?: number;
|
|
40
|
+
/** Pre-existing engagements (oldest-first) to start from. */
|
|
41
|
+
seed?: Engagement[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Process-local short-term memory: a trimmed array. Good for tests and
|
|
45
|
+
* single-process use; NOT persistent (use the Postgres-backed store for the
|
|
46
|
+
* "survives closing the chat" guarantee).
|
|
47
|
+
*/
|
|
48
|
+
export declare class InMemoryShortTermMemory implements ShortTermMemory {
|
|
49
|
+
private readonly capacity;
|
|
50
|
+
private engagements;
|
|
51
|
+
constructor(options?: InMemoryShortTermMemoryOptions);
|
|
52
|
+
recent(limit?: number): Promise<Engagement[]>;
|
|
53
|
+
append(engagement: Engagement): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Human "how long ago" between an engagement and the current event, for the
|
|
57
|
+
* prompt. Both are ISO strings; `now` is the current event's time, so the
|
|
58
|
+
* agent reads recency relative to what's happening now, not wall-clock.
|
|
59
|
+
*/
|
|
60
|
+
export declare function relativeTime(occurredAt: string, now: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Render the window into a prompt block, newest last, each line tagged with
|
|
63
|
+
* how long ago it was relative to `now`. Returns "" when the window is empty
|
|
64
|
+
* (so callers can omit the block entirely — the neutral case).
|
|
65
|
+
*/
|
|
66
|
+
export declare function renderEngagements(engagements: Engagement[], now: string): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Default number of engagements kept in the window. One engagement is one
|
|
2
|
+
* incoming event plus the agent's response to it. */
|
|
3
|
+
export const DEFAULT_STM_CAPACITY = 12;
|
|
4
|
+
/**
|
|
5
|
+
* Process-local short-term memory: a trimmed array. Good for tests and
|
|
6
|
+
* single-process use; NOT persistent (use the Postgres-backed store for the
|
|
7
|
+
* "survives closing the chat" guarantee).
|
|
8
|
+
*/
|
|
9
|
+
export class InMemoryShortTermMemory {
|
|
10
|
+
capacity;
|
|
11
|
+
engagements;
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.capacity = options.capacity ?? DEFAULT_STM_CAPACITY;
|
|
14
|
+
this.engagements = (options.seed ?? []).slice(-this.capacity);
|
|
15
|
+
}
|
|
16
|
+
async recent(limit) {
|
|
17
|
+
const n = limit ?? this.capacity;
|
|
18
|
+
return this.engagements.slice(-Math.max(0, n));
|
|
19
|
+
}
|
|
20
|
+
async append(engagement) {
|
|
21
|
+
this.engagements.push(engagement);
|
|
22
|
+
if (this.engagements.length > this.capacity) {
|
|
23
|
+
this.engagements = this.engagements.slice(-this.capacity);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Human "how long ago" between an engagement and the current event, for the
|
|
29
|
+
* prompt. Both are ISO strings; `now` is the current event's time, so the
|
|
30
|
+
* agent reads recency relative to what's happening now, not wall-clock.
|
|
31
|
+
*/
|
|
32
|
+
export function relativeTime(occurredAt, now) {
|
|
33
|
+
const then = Date.parse(occurredAt);
|
|
34
|
+
const ref = Date.parse(now);
|
|
35
|
+
if (Number.isNaN(then) || Number.isNaN(ref))
|
|
36
|
+
return "earlier";
|
|
37
|
+
const sec = Math.floor((ref - then) / 1000);
|
|
38
|
+
if (sec < 45)
|
|
39
|
+
return "just now";
|
|
40
|
+
const min = Math.floor(sec / 60);
|
|
41
|
+
if (min < 60)
|
|
42
|
+
return `${min}m ago`;
|
|
43
|
+
const hr = Math.floor(min / 60);
|
|
44
|
+
if (hr < 24)
|
|
45
|
+
return `${hr}h ago`;
|
|
46
|
+
const day = Math.floor(hr / 24);
|
|
47
|
+
if (day < 7)
|
|
48
|
+
return `${day}d ago`;
|
|
49
|
+
const wk = Math.floor(day / 7);
|
|
50
|
+
if (wk < 5)
|
|
51
|
+
return `${wk}w ago`;
|
|
52
|
+
const mo = Math.floor(day / 30);
|
|
53
|
+
if (mo < 12)
|
|
54
|
+
return `${mo}mo ago`;
|
|
55
|
+
return `${Math.floor(day / 365)}y ago`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Render the window into a prompt block, newest last, each line tagged with
|
|
59
|
+
* how long ago it was relative to `now`. Returns "" when the window is empty
|
|
60
|
+
* (so callers can omit the block entirely — the neutral case).
|
|
61
|
+
*/
|
|
62
|
+
export function renderEngagements(engagements, now) {
|
|
63
|
+
if (engagements.length === 0)
|
|
64
|
+
return "";
|
|
65
|
+
return engagements
|
|
66
|
+
.map((e) => {
|
|
67
|
+
const ago = relativeTime(e.occurredAt, now);
|
|
68
|
+
const responded = e.responded.trim() === "" ? "(stayed silent)" : `"${e.responded}"`;
|
|
69
|
+
return `- [${ago}] ${e.perceived} — you: ${responded}`;
|
|
70
|
+
})
|
|
71
|
+
.join("\n");
|
|
72
|
+
}
|