@cairnvibe/sdk 0.2.13 → 0.3.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.
@@ -1,7 +1,35 @@
1
1
  export declare class KeyRotator {
2
2
  private keys;
3
3
  private next;
4
+ private deadKeys;
4
5
  constructor(keys: string[]);
5
6
  static fromEnvList(value: string | undefined): KeyRotator | null;
7
+ /**
8
+ * Round-robins across whichever keys haven't been confirmed dead yet.
9
+ * If EVERY configured key has been marked dead (a real, if unlikely,
10
+ * total outage or a fully-expired key set), falls back to rotating
11
+ * across the full original list anyway — a bounded retry loop still
12
+ * needs something real to try, and refusing to ever retry again would
13
+ * turn "every key happens to be dead right now" into "permanently
14
+ * broken for the rest of this process," which is strictly worse.
15
+ */
6
16
  take(): string;
17
+ /**
18
+ * Marks a key as confirmed invalid (a real 401 from the provider, not a
19
+ * rate limit) — excluded from `take()`'s rotation for the rest of this
20
+ * process's life. Logs once per key the first time it's marked, naming
21
+ * only its last 4 characters (never the real secret) and how many
22
+ * configured keys still remain live, so a real deployment's own logs
23
+ * show exactly what happened instead of a silent, confusing drop in
24
+ * capacity.
25
+ */
26
+ markDead(key: string): void;
27
+ /** How many distinct keys are configured — callers use this to bound a
28
+ * rate-limit retry loop (no point trying more times than there are
29
+ * actual keys to fall back to). */
30
+ get size(): number;
31
+ /** How many configured keys have NOT been marked dead — narrower than
32
+ * `size` once markDead has actually excluded something; used to bound a
33
+ * retry loop against only the keys genuinely worth trying right now. */
34
+ get liveSize(): number;
7
35
  }
@@ -1,13 +1,28 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KeyRotator = void 0;
2
4
  // Round-robins across a comma-separated list of API keys (e.g. `GROQ_API_KEYS`)
3
5
  // so runtime verb calls spread across several free-tier rate limits instead
4
6
  // of hammering a single key. Mirrors packages/indexer/src/key-rotator.ts —
5
7
  // small enough that duplicating it beats adding a shared package for it.
6
- Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.KeyRotator = void 0;
8
+ //
9
+ // Real, live-found gap this closes: a key that's genuinely invalid/expired
10
+ // (a real 401, confirmed directly against Groq's own API — not a transient
11
+ // rate limit) used to just keep getting handed out by `take()` on every
12
+ // pass through the rotation, forever, for the life of the process — a
13
+ // configured-but-dead key wasn't just wasted capacity, it actively
14
+ // sabotaged roughly (dead keys / total keys) of every real request, since
15
+ // GroqVerbLLM's own retry-on-a-different-key logic only ever triggered on
16
+ // a 429 (rate limit), never a 401 (dead key) — see server.ts's own
17
+ // isInvalidKeyError. `markDead` is the fix: once a caller confirms a key
18
+ // is genuinely invalid (not just rate-limited), it's excluded from
19
+ // rotation for the rest of THIS process's life — a session-scoped
20
+ // blocklist, not a persisted one, since a key's validity is checked fresh
21
+ // every time this module is loaded (no stale cross-process assumptions).
8
22
  class KeyRotator {
9
23
  keys;
10
24
  next = 0;
25
+ deadKeys = new Set();
11
26
  constructor(keys) {
12
27
  if (keys.length === 0)
13
28
  throw new Error("KeyRotator: at least one key is required");
@@ -22,10 +37,49 @@ class KeyRotator {
22
37
  .filter(Boolean);
23
38
  return keys.length > 0 ? new KeyRotator(keys) : null;
24
39
  }
40
+ /**
41
+ * Round-robins across whichever keys haven't been confirmed dead yet.
42
+ * If EVERY configured key has been marked dead (a real, if unlikely,
43
+ * total outage or a fully-expired key set), falls back to rotating
44
+ * across the full original list anyway — a bounded retry loop still
45
+ * needs something real to try, and refusing to ever retry again would
46
+ * turn "every key happens to be dead right now" into "permanently
47
+ * broken for the rest of this process," which is strictly worse.
48
+ */
25
49
  take() {
26
- const key = this.keys[this.next % this.keys.length];
50
+ const liveKeys = this.keys.filter((k) => !this.deadKeys.has(k));
51
+ const pool = liveKeys.length > 0 ? liveKeys : this.keys;
52
+ const key = pool[this.next % pool.length];
27
53
  this.next += 1;
28
54
  return key;
29
55
  }
56
+ /**
57
+ * Marks a key as confirmed invalid (a real 401 from the provider, not a
58
+ * rate limit) — excluded from `take()`'s rotation for the rest of this
59
+ * process's life. Logs once per key the first time it's marked, naming
60
+ * only its last 4 characters (never the real secret) and how many
61
+ * configured keys still remain live, so a real deployment's own logs
62
+ * show exactly what happened instead of a silent, confusing drop in
63
+ * capacity.
64
+ */
65
+ markDead(key) {
66
+ if (this.deadKeys.has(key))
67
+ return;
68
+ this.deadKeys.add(key);
69
+ const remaining = this.keys.length - this.deadKeys.size;
70
+ console.warn(`[cairn] API key ending in "${key.slice(-4)}" is invalid (confirmed via a real 401) — excluded from rotation for the rest of this session. ${remaining} of ${this.keys.length} configured key(s) remain.`);
71
+ }
72
+ /** How many distinct keys are configured — callers use this to bound a
73
+ * rate-limit retry loop (no point trying more times than there are
74
+ * actual keys to fall back to). */
75
+ get size() {
76
+ return this.keys.length;
77
+ }
78
+ /** How many configured keys have NOT been marked dead — narrower than
79
+ * `size` once markDead has actually excluded something; used to bound a
80
+ * retry loop against only the keys genuinely worth trying right now. */
81
+ get liveSize() {
82
+ return this.keys.length - this.deadKeys.size;
83
+ }
30
84
  }
31
85
  exports.KeyRotator = KeyRotator;
@@ -0,0 +1,86 @@
1
+ import Database from "better-sqlite3";
2
+ import type { HistoryTurn } from "@cairnvibe/core";
3
+ export interface MemoryTurnRecord {
4
+ role: "user" | "assistant";
5
+ content: string;
6
+ createdAt: string;
7
+ }
8
+ export interface MemoryStore {
9
+ /** Core tier — explicit remember, upsert by (scopeId, key), same as
10
+ * Track B's own "remember" being an explicit act, never automatic.
11
+ * Kept small on purpose (MAX_CORE_FACTS_PER_SCOPE): once a scope's Core
12
+ * facts exceed the cap, the least-recently-updated ones are moved to
13
+ * the Archive tier below (see this file's own doc comment) rather than
14
+ * deleted outright — "the piece that lets memory scale" the plan calls
15
+ * for, not a data-loss cliff. */
16
+ rememberFact(scopeId: string, key: string, value: string): void;
17
+ recallFact(scopeId: string, key: string): string | null;
18
+ /** Every CORE fact for this scope, key -> value — never includes Archive tier facts (see recallArchivedFacts for those). */
19
+ recallFacts(scopeId: string): Record<string, string>;
20
+ /** Recall tier — append-only, one row per turn, both roles. */
21
+ recordTurn(scopeId: string, role: "user" | "assistant", content: string): void;
22
+ /** Recall tier — recency-ordered, oldest-first (ready to feed straight into a HistoryTurn[] array). */
23
+ recentTurns(scopeId: string, limit?: number): MemoryTurnRecord[];
24
+ /**
25
+ * Architecture Pillar 5 — the Recall tier's own real search: a keyword
26
+ * match against past turn content, not just a recency LIMIT. Lets a
27
+ * later question reach back further than `recentTurns`' own window
28
+ * without loading the ENTIRE history every time. Recency-ordered
29
+ * (newest match first) — no relevance ranking beyond "matched at all,"
30
+ * a deliberately simple v1.
31
+ */
32
+ searchTurns(scopeId: string, query: string, limit?: number): MemoryTurnRecord[];
33
+ /**
34
+ * Architecture Pillar 5 — the Archive tier. Long-term facts pulled in
35
+ * only when a real query actually relates to them, never always-
36
+ * injected the way Core facts are (that's the whole point — this is
37
+ * what lets memory scale past Core's small cap without either losing
38
+ * old facts or paying to inject all of them on every single turn).
39
+ * Populated automatically when `rememberFact` evicts an over-cap Core
40
+ * fact; a caller may also archive something directly if it never
41
+ * belonged in the small, always-injected Core set to begin with.
42
+ */
43
+ archiveFact(scopeId: string, key: string, value: string): void;
44
+ /** Keyword match against BOTH key and value, recency-ordered. Empty object for no match — never guesses at relevance. */
45
+ recallArchivedFacts(scopeId: string, query: string, limit?: number): Record<string, string>;
46
+ }
47
+ /**
48
+ * @param target Either a file path (opened/created, parent dir made if
49
+ * needed) or an already-open better-sqlite3 `Database` — pass an open
50
+ * connection to share it with your own tables instead of opening a second file.
51
+ */
52
+ export declare function createSqliteMemoryStore(target: string | Database.Database): MemoryStore;
53
+ /**
54
+ * Phase 5 step 4 — pure, standalone, directly testable (no closures, no
55
+ * DB, no WebSocket), storage-agnostic (works from a `MemoryStore`
56
+ * however it's actually backed) — shared by BOTH transports (the
57
+ * realtime relay, which seeds this once per connection, and the typed/
58
+ * HTTP handler, which seeds it once per genuinely fresh session — see
59
+ * server.ts's own use). Prior turns go FIRST (oldest overall), any
60
+ * already-accumulated history stays after them, then the whole thing is
61
+ * capped to `maxTurns` — same cap `history` itself already uses
62
+ * everywhere else, just applied once more here so a scope with a long
63
+ * real memory can't blow past it the moment a session starts.
64
+ */
65
+ export declare function seedHistoryFromMemory(existingHistory: readonly HistoryTurn[], priorTurns: readonly MemoryTurnRecord[], maxTurns: number): HistoryTurn[];
66
+ /**
67
+ * Phase 5 step 3 — closes the loop step 2 opened: a fact the model
68
+ * explicitly remembered was being written but never read back into a
69
+ * LATER turn's context, only `recentTurns`' raw conversation text
70
+ * (unstructured, unreliable) had any chance of mentioning it. Pure and
71
+ * standalone for the same reason as `seedHistoryFromMemory` — directly
72
+ * testable with a plain object, no store, no connection. Returns null
73
+ * for an empty fact set (nothing to say) rather than an empty string,
74
+ * so a caller can cleanly skip adding a turn at all.
75
+ */
76
+ export declare function formatRememberedFacts(facts: Readonly<Record<string, string>>): string | null;
77
+ /**
78
+ * Architecture Pillar 5 — the Archive tier's own version of
79
+ * formatRememberedFacts, worded to make the tier distinction legible to
80
+ * the model instead of silently blending long-since-archived facts in
81
+ * with the small, always-injected Core set (the two ARE genuinely
82
+ * different: Core is curated and small; this only ever shows up because
83
+ * this exact turn's own question happened to relate to it). Same null-
84
+ * for-empty discipline as its Core counterpart.
85
+ */
86
+ export declare function formatArchivedFacts(facts: Readonly<Record<string, string>>): string | null;
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ // Phase 5 — real cross-session memory, backed by SQLite. Shape-inspired
3
+ // by (not copied from) Track B's services/graph/src/cairn_graph/memory.py
4
+ // (facts: an explicit upsert keyed by (scope, key); turns: append-only,
5
+ // recency-ordered recall, no search) — reimplemented here because Track
6
+ // B's version has no real tenant/scope isolation and wasn't built for
7
+ // Track A's actual runtime. Mirrors dashboard-sqlite.ts's own shape
8
+ // (file-or-shared-Database, a namespaced table, plain better-sqlite3) —
9
+ // the same real, already-shipped pattern, not a new one.
10
+ //
11
+ // `scopeId` is deliberately opaque and caller-supplied, never invented
12
+ // here: Track A has no existing identity concept anywhere (no userId/
13
+ // sessionId/tenantId in this SDK today — confirmed before writing this).
14
+ // A caller passes whatever it already has — a Cairn customer's own
15
+ // end-user id if they have login, or any other stable string they
16
+ // choose — and gets exactly the isolation that string implies. This
17
+ // store makes no claim about WHO a scope actually is.
18
+ var __importDefault = (this && this.__importDefault) || function (mod) {
19
+ return (mod && mod.__esModule) ? mod : { "default": mod };
20
+ };
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.createSqliteMemoryStore = createSqliteMemoryStore;
23
+ exports.seedHistoryFromMemory = seedHistoryFromMemory;
24
+ exports.formatRememberedFacts = formatRememberedFacts;
25
+ exports.formatArchivedFacts = formatArchivedFacts;
26
+ const node_fs_1 = __importDefault(require("node:fs"));
27
+ const node_path_1 = __importDefault(require("node:path"));
28
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
29
+ const FACTS_TABLE = "cairn_memory_facts";
30
+ const TURNS_TABLE = "cairn_memory_turns";
31
+ const ARCHIVE_TABLE = "cairn_memory_archive";
32
+ const DEFAULT_RECENT_TURNS = 20;
33
+ const DEFAULT_SEARCH_LIMIT = 5;
34
+ // Architecture Pillar 5 — MemGPT-shaped tiered memory. A REAL, measured
35
+ // finding motivates this, not a hunch: swapping a tiered-memory agent for
36
+ // a flat/long-context-only one dropped multi-session task completion from
37
+ // ~80% to ~45% (MemoryArena, arxiv 2603.07670). Core is deliberately kept
38
+ // SMALL — "durable, curated facts, always injected" only means something
39
+ // if the set actually stays small; a scope that just accumulates facts
40
+ // forever isn't curated, it's a second, worse-organized turn log. Chosen
41
+ // as a real, testable number (not tuned against production data, which
42
+ // doesn't exist yet for this) — see this file's own tests for the exact
43
+ // eviction behavior at the boundary.
44
+ const MAX_CORE_FACTS_PER_SCOPE = 20;
45
+ /** Real, significant (4+ letter) words, lowercased — the same crude but
46
+ * dependency-free keyword-matching approach server.ts's Skill retrieval
47
+ * (matchSkillByGoal) already established, reused here for the same
48
+ * "cheap, deterministic, no new dependency" reasoning: no FTS5 extension
49
+ * required, no semantic embedding call, just real substring matching
50
+ * against words a human would actually recognize as meaningful. */
51
+ function significantWords(text) {
52
+ return text
53
+ .toLowerCase()
54
+ .split(/[^a-z0-9]+/)
55
+ .filter((w) => w.length >= 4);
56
+ }
57
+ /**
58
+ * @param target Either a file path (opened/created, parent dir made if
59
+ * needed) or an already-open better-sqlite3 `Database` — pass an open
60
+ * connection to share it with your own tables instead of opening a second file.
61
+ */
62
+ function createSqliteMemoryStore(target) {
63
+ const db = typeof target === "string" ? openFile(target) : target;
64
+ db.exec(`
65
+ CREATE TABLE IF NOT EXISTS ${FACTS_TABLE} (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ scope_id TEXT NOT NULL,
68
+ key TEXT NOT NULL,
69
+ value TEXT NOT NULL,
70
+ updated_at TEXT NOT NULL,
71
+ UNIQUE(scope_id, key)
72
+ )
73
+ `);
74
+ db.exec(`
75
+ CREATE TABLE IF NOT EXISTS ${TURNS_TABLE} (
76
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77
+ scope_id TEXT NOT NULL,
78
+ role TEXT NOT NULL,
79
+ content TEXT NOT NULL,
80
+ created_at TEXT NOT NULL
81
+ )
82
+ `);
83
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_${TURNS_TABLE}_scope ON ${TURNS_TABLE}(scope_id, id)`);
84
+ db.exec(`
85
+ CREATE TABLE IF NOT EXISTS ${ARCHIVE_TABLE} (
86
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ scope_id TEXT NOT NULL,
88
+ key TEXT NOT NULL,
89
+ value TEXT NOT NULL,
90
+ updated_at TEXT NOT NULL,
91
+ UNIQUE(scope_id, key)
92
+ )
93
+ `);
94
+ const upsertFact = db.prepare(`
95
+ INSERT INTO ${FACTS_TABLE} (scope_id, key, value, updated_at) VALUES (?, ?, ?, ?)
96
+ ON CONFLICT(scope_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
97
+ `);
98
+ const selectFact = db.prepare(`SELECT value FROM ${FACTS_TABLE} WHERE scope_id = ? AND key = ?`);
99
+ const selectAllFacts = db.prepare(`SELECT key, value FROM ${FACTS_TABLE} WHERE scope_id = ?`);
100
+ const countFacts = db.prepare(`SELECT COUNT(*) as count FROM ${FACTS_TABLE} WHERE scope_id = ?`);
101
+ // ORDER BY updated_at, then id — a real tiebreaker: several facts
102
+ // written in the same millisecond (easily happens in a tight loop, or
103
+ // any two facts genuinely remembered together) would otherwise leave
104
+ // "least-recently-updated" ambiguous, since SQLite makes no ordering
105
+ // guarantee among rows with an equal ORDER BY key. Falling back to
106
+ // insertion order (id) is the correct default when two facts are
107
+ // equally "old" by their real timestamp.
108
+ const selectOldestFacts = db.prepare(`SELECT key, value, updated_at FROM ${FACTS_TABLE} WHERE scope_id = ? ORDER BY updated_at ASC, id ASC LIMIT ?`);
109
+ const deleteFact = db.prepare(`DELETE FROM ${FACTS_TABLE} WHERE scope_id = ? AND key = ?`);
110
+ const insertTurn = db.prepare(`INSERT INTO ${TURNS_TABLE} (scope_id, role, content, created_at) VALUES (?, ?, ?, ?)`);
111
+ const selectRecentTurns = db.prepare(`SELECT role, content, created_at FROM ${TURNS_TABLE} WHERE scope_id = ? ORDER BY id DESC LIMIT ?`);
112
+ const upsertArchiveFact = db.prepare(`
113
+ INSERT INTO ${ARCHIVE_TABLE} (scope_id, key, value, updated_at) VALUES (?, ?, ?, ?)
114
+ ON CONFLICT(scope_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
115
+ `);
116
+ function archiveFactImpl(scopeId, key, value, updatedAt) {
117
+ upsertArchiveFact.run(scopeId, key, value, updatedAt);
118
+ }
119
+ /** Enforces MAX_CORE_FACTS_PER_SCOPE after a real write — moves the
120
+ * least-recently-updated Core facts (oldest `updated_at` first) to
121
+ * Archive instead of deleting them, until back at the cap. Never
122
+ * evicts the fact that was just written (it's always the most
123
+ * recently updated, so ORDER BY updated_at ASC naturally excludes it
124
+ * as long as the cap is >= 1). */
125
+ function enforceCoreCap(scopeId) {
126
+ const { count } = countFacts.get(scopeId);
127
+ const overflow = count - MAX_CORE_FACTS_PER_SCOPE;
128
+ if (overflow <= 0)
129
+ return;
130
+ const toEvict = selectOldestFacts.all(scopeId, overflow);
131
+ for (const row of toEvict) {
132
+ archiveFactImpl(scopeId, row.key, row.value, row.updated_at);
133
+ deleteFact.run(scopeId, row.key);
134
+ }
135
+ }
136
+ return {
137
+ rememberFact(scopeId, key, value) {
138
+ upsertFact.run(scopeId, key, value, new Date().toISOString());
139
+ enforceCoreCap(scopeId);
140
+ },
141
+ recallFact(scopeId, key) {
142
+ const row = selectFact.get(scopeId, key);
143
+ return row?.value ?? null;
144
+ },
145
+ recallFacts(scopeId) {
146
+ const rows = selectAllFacts.all(scopeId);
147
+ return Object.fromEntries(rows.map((r) => [r.key, r.value]));
148
+ },
149
+ recordTurn(scopeId, role, content) {
150
+ insertTurn.run(scopeId, role, content, new Date().toISOString());
151
+ },
152
+ recentTurns(scopeId, limit = DEFAULT_RECENT_TURNS) {
153
+ const rows = selectRecentTurns.all(scopeId, limit);
154
+ // DESC + LIMIT gets the N most recent, then reversed to oldest-first — ready to feed straight into a HistoryTurn[]-shaped array, same convention that array already uses.
155
+ return rows.reverse().map((r) => ({ role: r.role, content: r.content, createdAt: r.created_at }));
156
+ },
157
+ searchTurns(scopeId, query, limit = DEFAULT_SEARCH_LIMIT) {
158
+ const words = significantWords(query);
159
+ if (words.length === 0)
160
+ return [];
161
+ const clause = words.map(() => "content LIKE ?").join(" OR ");
162
+ const params = words.map((w) => `%${w}%`);
163
+ const rows = db.prepare(`SELECT role, content, created_at FROM ${TURNS_TABLE} WHERE scope_id = ? AND (${clause}) ORDER BY id DESC LIMIT ?`).all(scopeId, ...params, limit);
164
+ return rows.reverse().map((r) => ({ role: r.role, content: r.content, createdAt: r.created_at }));
165
+ },
166
+ archiveFact(scopeId, key, value) {
167
+ archiveFactImpl(scopeId, key, value, new Date().toISOString());
168
+ },
169
+ recallArchivedFacts(scopeId, query, limit = DEFAULT_SEARCH_LIMIT) {
170
+ const words = significantWords(query);
171
+ if (words.length === 0)
172
+ return {};
173
+ const clause = words.map(() => "(key LIKE ? OR value LIKE ?)").join(" OR ");
174
+ const params = words.flatMap((w) => [`%${w}%`, `%${w}%`]);
175
+ const rows = db.prepare(`SELECT key, value FROM ${ARCHIVE_TABLE} WHERE scope_id = ? AND (${clause}) ORDER BY updated_at DESC LIMIT ?`).all(scopeId, ...params, limit);
176
+ return Object.fromEntries(rows.map((r) => [r.key, r.value]));
177
+ },
178
+ };
179
+ }
180
+ function openFile(filePath) {
181
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
182
+ return new better_sqlite3_1.default(filePath);
183
+ }
184
+ /**
185
+ * Phase 5 step 4 — pure, standalone, directly testable (no closures, no
186
+ * DB, no WebSocket), storage-agnostic (works from a `MemoryStore`
187
+ * however it's actually backed) — shared by BOTH transports (the
188
+ * realtime relay, which seeds this once per connection, and the typed/
189
+ * HTTP handler, which seeds it once per genuinely fresh session — see
190
+ * server.ts's own use). Prior turns go FIRST (oldest overall), any
191
+ * already-accumulated history stays after them, then the whole thing is
192
+ * capped to `maxTurns` — same cap `history` itself already uses
193
+ * everywhere else, just applied once more here so a scope with a long
194
+ * real memory can't blow past it the moment a session starts.
195
+ */
196
+ function seedHistoryFromMemory(existingHistory, priorTurns, maxTurns) {
197
+ const combined = [...priorTurns.map((t) => ({ role: t.role, text: t.content })), ...existingHistory];
198
+ return combined.slice(Math.max(0, combined.length - maxTurns));
199
+ }
200
+ /**
201
+ * Phase 5 step 3 — closes the loop step 2 opened: a fact the model
202
+ * explicitly remembered was being written but never read back into a
203
+ * LATER turn's context, only `recentTurns`' raw conversation text
204
+ * (unstructured, unreliable) had any chance of mentioning it. Pure and
205
+ * standalone for the same reason as `seedHistoryFromMemory` — directly
206
+ * testable with a plain object, no store, no connection. Returns null
207
+ * for an empty fact set (nothing to say) rather than an empty string,
208
+ * so a caller can cleanly skip adding a turn at all.
209
+ */
210
+ function formatRememberedFacts(facts) {
211
+ const entries = Object.entries(facts);
212
+ if (entries.length === 0)
213
+ return null;
214
+ return `Remembered from a previous conversation with this user: ${entries.map(([key, value]) => `${key} — ${value}`).join("; ")}.`;
215
+ }
216
+ /**
217
+ * Architecture Pillar 5 — the Archive tier's own version of
218
+ * formatRememberedFacts, worded to make the tier distinction legible to
219
+ * the model instead of silently blending long-since-archived facts in
220
+ * with the small, always-injected Core set (the two ARE genuinely
221
+ * different: Core is curated and small; this only ever shows up because
222
+ * this exact turn's own question happened to relate to it). Same null-
223
+ * for-empty discipline as its Core counterpart.
224
+ */
225
+ function formatArchivedFacts(facts) {
226
+ const entries = Object.entries(facts);
227
+ if (entries.length === 0)
228
+ return null;
229
+ return `Also found in older, archived memory (relevant to this question): ${entries.map(([key, value]) => `${key} — ${value}`).join("; ")}.`;
230
+ }
@@ -22,6 +22,8 @@ const node_path_1 = __importDefault(require("node:path"));
22
22
  const node_child_process_1 = require("node:child_process");
23
23
  const core_1 = require("@cairnvibe/core");
24
24
  const realtime_server_1 = require("./realtime-server");
25
+ const memory_sqlite_1 = require("./memory-sqlite");
26
+ const skill_store_1 = require("./skill-store");
25
27
  function parseCapability(raw) {
26
28
  if (raw === "explain" || raw === "guide" || raw === "act")
27
29
  return raw;
@@ -124,7 +126,26 @@ function main() {
124
126
  const port = parsePortFlag(process.argv.slice(2)) ?? Number(process.env.CAIRN_REALTIME_PORT ?? 3010);
125
127
  const capability = parseCapability(process.env.CAIRN_CAPABILITY);
126
128
  const persona = process.env.CAIRN_PERSONA || undefined;
127
- const server = (0, realtime_server_1.createRealtimeServer)({ manifest, provider, deepgramApiKey, registeredActions, capability, persona });
129
+ // Phase 5 / Architecture Pillar 5 real cross-session memory, opt-in
130
+ // via a real file path. Closes the gap DEVELOPMENT.md's own Pillar 5
131
+ // entry flagged: MemoryStore was wired into createCopilotHandler and
132
+ // ConnectionDeps from the start, but never actually reachable from
133
+ // this CLI — a real deployment had no zero-code way to turn it on for
134
+ // the realtime relay. Absent env var means exactly today's behavior:
135
+ // no memory, zero overhead.
136
+ const memoryDbPath = process.env.CAIRN_MEMORY_DB_PATH;
137
+ const memory = memoryDbPath ? (0, memory_sqlite_1.createSqliteMemoryStore)(node_path_1.default.resolve(process.cwd(), memoryDbPath)) : undefined;
138
+ // Architecture Pillar 3 (Skill half) — same real, previously-missing
139
+ // wiring for self-authored Skills. Deliberately a SEPARATE file/scope
140
+ // from memory (see skill-store.ts's own doc comment: Skills are
141
+ // per-deployment, memory is per-user) — sharing the same underlying
142
+ // sqlite file is still fine if a deployment points both env vars at
143
+ // the same path, since each store creates its own distinctly-named
144
+ // tables.
145
+ const skillsDbPath = process.env.CAIRN_SKILLS_DB_PATH;
146
+ const skills = skillsDbPath ? (0, skill_store_1.createSqliteSkillStore)(node_path_1.default.resolve(process.cwd(), skillsDbPath)) : undefined;
147
+ const skillsScopeId = process.env.CAIRN_SKILLS_SCOPE_ID || undefined;
148
+ const server = (0, realtime_server_1.createRealtimeServer)({ manifest, provider, deepgramApiKey, registeredActions, capability, persona, memory, skills, skillsScopeId });
128
149
  server.listen(port, () => {
129
150
  console.error(`cairn-realtime: listening on ws://localhost:${port} (provider: ${provider})`);
130
151
  if (withCommand)
@@ -1,23 +1,74 @@
1
1
  import http from "node:http";
2
2
  import { WebSocket } from "ws";
3
3
  import { type HistoryTurn, type LiveElement, type Manifest, type WebMcpTool } from "@cairnvibe/core";
4
- import { createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
4
+ import { createCriticLLM, createPlanLLM, createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
5
+ import { formatRememberedFacts, seedHistoryFromMemory, type MemoryStore } from "./memory-sqlite";
6
+ import type { SkillStore } from "./skill-store";
5
7
  export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
6
8
  manifest: Manifest;
7
9
  deepgramApiKey: string;
8
10
  sttModel?: string;
9
11
  ttsVoice?: string;
12
+ /** Phase 5 — real cross-session memory (packages/sdk/src/memory-sqlite.ts,
13
+ * or any store implementing the same interface). Optional — omitting it
14
+ * keeps every connection exactly as memory-less as before this existed.
15
+ * Scoped by whatever `scopeId` string a connection's own client sends in
16
+ * its "context" message (see ConnectionDeps' own doc comment) — this SDK
17
+ * invents no identity of its own. */
18
+ memory?: MemoryStore;
19
+ /** Architecture Pillar 3 (Skill half) — see ConnectionDeps' own doc
20
+ * comment. Optional; omitting it keeps every connection exactly as it
21
+ * was before this existed. */
22
+ skills?: SkillStore;
23
+ /** See ConnectionDeps' own doc comment. Defaults to "default" when `skills` is set but this is omitted. */
24
+ skillsScopeId?: string;
10
25
  }
26
+ export { seedHistoryFromMemory, formatRememberedFacts };
11
27
  export declare function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server;
12
28
  export interface ConnectionDeps {
13
29
  deepgramApiKey: string;
14
30
  sttModel: string;
15
31
  ttsVoice: string;
16
32
  llm: ReturnType<typeof createVerbLLM>;
33
+ /** Phase 3 step 2 — a separately-configured Planner LLM, called on the
34
+ * first continuing step of a turn (see finalizeTurn). Optional so
35
+ * existing ConnectionDeps construction (and every existing test) keeps
36
+ * working unchanged; absent means no Planner call happens at all. */
37
+ planLLM?: ReturnType<typeof createPlanLLM>;
38
+ /** Phase 3 step 3 — a separately-configured Critic LLM. Only engages
39
+ * (task-advancement/replan/give-up actually driving the loop, not just
40
+ * logging) when BOTH this and planLLM are present — the Critic needs a
41
+ * real Plan's current task to check against. Optional for the same
42
+ * backward-compatibility reason as planLLM. */
43
+ criticLLM?: ReturnType<typeof createCriticLLM>;
17
44
  systemPrompt: string;
18
45
  manifest: Manifest;
19
46
  registeredActions: string[];
47
+ /** Phase 4 step 4 — real descriptions for registeredActions ids, same
48
+ * shape/purpose as CreateCopilotHandlerOptions.actionDescriptions.
49
+ * Optional, defaults to {} — an existing ConnectionDeps construction
50
+ * (own or a test's) keeps working with every action rendered bare. */
51
+ actionDescriptions?: Record<string, string>;
20
52
  capability: CapabilityTier;
53
+ /** Phase 5 — see CreateRealtimeServerOptions' own doc comment. Optional,
54
+ * same backward-compatibility reason as every other addition here:
55
+ * absent means no memory read/write happens for any connection, ever
56
+ * — today's exact behavior. */
57
+ memory?: MemoryStore;
58
+ /**
59
+ * Architecture Pillar 3 (Skill half) — real, persistent storage for
60
+ * self-authored Skills (skill-store.ts). A DIFFERENT axis of scope than
61
+ * `memory` above: Skills are meant to be shared across every user who
62
+ * talks to this deployment (the same scope `ui-manifest.json` itself
63
+ * already has), never per-user — see skill-store.ts's own doc comment.
64
+ * Optional; absent means no Skill retrieval/saving happens at all, zero
65
+ * overhead, today's exact behavior.
66
+ */
67
+ skills?: SkillStore;
68
+ /** The deployment-wide scope Skills are stored/looked up under when
69
+ * `skills` is configured. Defaults to "default" — a single-deployment
70
+ * setup, today's only real usage — when omitted. */
71
+ skillsScopeId?: string;
21
72
  }
22
73
  export declare function handleDeepgramMessage(raw: string, client: WebSocket, deps: ConnectionDeps, getContext: () => {
23
74
  route: string;
@@ -26,4 +77,34 @@ export declare function handleDeepgramMessage(raw: string, client: WebSocket, de
26
77
  webMcpTools: WebMcpTool[];
27
78
  }, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
28
79
  buffer: string;
29
- }, getGeneration: () => number, waitForToolResult: () => Promise<string>): Promise<void>;
80
+ }, getGeneration: () => number, waitForToolResult: () => Promise<string>,
81
+ /** Phase 5 — called with each real (role, text) turn as it's finalized,
82
+ * right alongside the same-shaped `history.push`. Optional and a no-op
83
+ * by default so every existing call site keeps working unchanged. The
84
+ * realtime connection's own recordMemoryTurn writes it to durable
85
+ * storage when memory + a scopeId are both configured for this
86
+ * connection — see ConnectionDeps.memory's own doc comment. */
87
+ recordMemoryTurn?: (role: "user" | "assistant", text: string) => void,
88
+ /** Phase 5 step 2 — see finalizeTurn's own doc comment. Threaded
89
+ * through here purely to reach finalizeTurn's two call sites below. */
90
+ getScopeId?: () => string | null,
91
+ /** Real, live-found gap this closes: `generation` (getGeneration/
92
+ * triggerServerBargeIn) previously only ever bumped on an EXPLICIT
93
+ * barge-in — two ordinary, sequential turns with no interruption
94
+ * between them shared the exact same generation number. That was
95
+ * fine for what `generation` was originally built for (dropping
96
+ * audio/verbs abandoned mid-turn by a real interruption), but it
97
+ * left the CLIENT's own generation-based staleness check (added for
98
+ * that same reason, in index.tsx) with no way to tell a merely SLOW
99
+ * turn's late-arriving reply apart from the current one — nothing
100
+ * had bumped, so the late reply's generation still matched. Found
101
+ * live: a "hello" reply that took long enough to arrive AFTER the
102
+ * next question's own "final" had already fired, landing on the
103
+ * wrong caption because both were tagged the same generation.
104
+ * Called once per genuinely NEW turn (both call sites below), so
105
+ * every real "final" gets its own fresh generation — a turn is now
106
+ * "superseded" the instant a newer one starts, not only when an
107
+ * explicit interruption says so. Optional and a no-op by default so
108
+ * every existing call site (own or a test's) that doesn't pass this
109
+ * keeps behaving exactly as before. */
110
+ bumpGeneration?: () => void): Promise<void>;