@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.
- package/dist/agent-loop.d.ts +113 -0
- package/dist/agent-loop.js +128 -0
- package/dist/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +71 -0
- package/dist/element-ladder.js +168 -0
- package/dist/index.d.ts +79 -1
- package/dist/index.js +864 -89
- package/dist/key-rotator.d.ts +28 -0
- package/dist/key-rotator.js +57 -3
- package/dist/memory-sqlite.d.ts +86 -0
- package/dist/memory-sqlite.js +230 -0
- package/dist/realtime-cli.js +22 -1
- package/dist/realtime-server.d.ts +83 -2
- package/dist/realtime-server.js +549 -120
- package/dist/server.d.ts +249 -5
- package/dist/server.js +984 -77
- package/dist/skill-store.d.ts +17 -0
- package/dist/skill-store.js +78 -0
- package/dist/tts-stream.d.ts +25 -0
- package/dist/tts-stream.js +32 -0
- package/dist/vad.d.ts +27 -0
- package/dist/vad.js +128 -0
- package/dist/verb-executor.d.ts +32 -11
- package/dist/verb-executor.js +224 -16
- package/dist/webmcp-client.d.ts +14 -1
- package/dist/webmcp-client.js +22 -1
- package/package.json +3 -1
- package/src/agent-loop.ts +222 -0
- package/src/element-ladder.ts +170 -0
- package/src/index.tsx +914 -93
- package/src/key-rotator.ts +57 -2
- package/src/memory-sqlite.ts +283 -0
- package/src/realtime-cli.ts +24 -1
- package/src/realtime-server.ts +655 -122
- package/src/server.ts +1077 -77
- package/src/skill-store.ts +88 -0
- package/src/tts-stream.ts +30 -0
- package/src/vad.ts +153 -0
- package/src/verb-executor.ts +243 -22
- package/src/web-component.ts +82 -17
- package/src/webmcp-client.ts +30 -2
package/src/key-rotator.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/realtime-cli.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|