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