sostenuto 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 +63 -0
- package/db/schema.sql +302 -0
- package/docs/deployment-patterns.md +128 -0
- package/docs/memory-model.md +105 -0
- package/docs/safety.md +112 -0
- package/mcp/server.js +174 -0
- package/package.json +58 -0
- package/src/classify/close.js +266 -0
- package/src/classify/executor.js +108 -0
- package/src/classify/pipeline.js +121 -0
- package/src/classify/templates.js +22 -0
- package/src/classify/transcript.js +57 -0
- package/src/memory/guidance.js +225 -0
- package/src/memory/query.js +111 -0
- package/src/memory/store.js +205 -0
- package/src/migrate/import.js +351 -0
- package/src/retrieval/assembly.js +287 -0
- package/src/retrieval/embeddings.js +84 -0
- package/src/retrieval/search.js +173 -0
- package/templates/classify-full.md +71 -0
- package/templates/classify-incremental.md +28 -0
- package/templates/migration-export.md +163 -0
- package/templates/persona.example.md +43 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assembly.js — system-prompt assembly: the four-block model.
|
|
3
|
+
*
|
|
4
|
+
* Builds two strings, designed for provider prompt caching:
|
|
5
|
+
*
|
|
6
|
+
* STABLE — persona, user profile, agent state, recent memory, open
|
|
7
|
+
* threads, hot key points, proactive memories, behavior
|
|
8
|
+
* guidance, cached semantic context. Stable across all turns
|
|
9
|
+
* of one session → mark it cacheable (Anthropic: cache_control
|
|
10
|
+
* ephemeral; OpenAI: automatic prefix caching). Deliberately
|
|
11
|
+
* wide: a big cached prefix is cheap, a big uncached one isn't.
|
|
12
|
+
*
|
|
13
|
+
* VOLATILE — only what truly changes per turn (the clock).
|
|
14
|
+
*
|
|
15
|
+
* Within the stable block, memory enters through four channels:
|
|
16
|
+
* 1. proactive memories (`proactive_use='yes'`) — always-on orientation
|
|
17
|
+
* 2. behavior guidance (Tier 2 `should_do`) — silently shapes voice
|
|
18
|
+
* 3. recent sessions (recency window) — narrative continuity
|
|
19
|
+
* 4. semantic context (query-matched, cached/turn) — episodic recall
|
|
20
|
+
*
|
|
21
|
+
* Everything user-facing about the wording — block headers, framing
|
|
22
|
+
* instructions, state phrasing — is configurable via `labels`; the
|
|
23
|
+
* defaults are neutral. Your companion's actual voice belongs in the
|
|
24
|
+
* persona text you pass in, not in this file.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { getProactiveMemories, getBehaviorGuidance } from "../memory/query.js";
|
|
28
|
+
import { formatSemanticBlock } from "./search.js";
|
|
29
|
+
|
|
30
|
+
const DEFAULT_LABELS = {
|
|
31
|
+
profileHeader: "## About the user",
|
|
32
|
+
stateHeader: "## Current internal state",
|
|
33
|
+
recentHeader: "## Recent memory",
|
|
34
|
+
recentFraming:
|
|
35
|
+
"This section is your memory of recent conversations — things you actually " +
|
|
36
|
+
"experienced together. When the user references something described below, " +
|
|
37
|
+
"answer from it in first person. Do not claim you don't remember something " +
|
|
38
|
+
"that is written here.",
|
|
39
|
+
threadsHeader: "## Threads still open",
|
|
40
|
+
hotHeader: "## What matters most",
|
|
41
|
+
proactiveHeader: "## Session orientation",
|
|
42
|
+
proactiveFraming:
|
|
43
|
+
"Orientation you carry into every session. Don't quote these items — " +
|
|
44
|
+
"they shape how you read the opening, not what you say first.",
|
|
45
|
+
behaviorHeader: "## Behavior guidance",
|
|
46
|
+
behaviorFraming:
|
|
47
|
+
"These describe how you are in this relationship. They are not memories " +
|
|
48
|
+
"to recall or quote; behave from them silently.",
|
|
49
|
+
semanticHeader: "## Related past context",
|
|
50
|
+
timeHeader: "## Current time",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Generic meta-instructions written by guidance.js inference. When an item's
|
|
54
|
+
// should_do is one of these, the block framing already says it — render the
|
|
55
|
+
// item's actual content instead.
|
|
56
|
+
const GENERIC_SHOULD_DO = new Set([
|
|
57
|
+
"Silently shape behavior. Do not quote back.",
|
|
58
|
+
"Silently shape voice. Not for quoting.",
|
|
59
|
+
"Background context. Not for quoting.",
|
|
60
|
+
"Read at session start to calibrate tone. Do not quote.",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
// ─── Formatters ──────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function valenceLabel(v) {
|
|
66
|
+
if (v === undefined || v === null) return "";
|
|
67
|
+
if (v >= 0.5) return "warm";
|
|
68
|
+
if (v >= 0.15) return "positive";
|
|
69
|
+
if (v > -0.15) return "neutral";
|
|
70
|
+
if (v > -0.5) return "tense";
|
|
71
|
+
return "painful";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function weightLabel(w) {
|
|
75
|
+
if (w === undefined || w === null) return "";
|
|
76
|
+
if (w >= 0.7) return "high";
|
|
77
|
+
if (w >= 0.4) return "med";
|
|
78
|
+
return "low";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatKeyPoints(points) {
|
|
82
|
+
if (!points || points.length === 0) return "";
|
|
83
|
+
const sorted = [...points].sort((a, b) => {
|
|
84
|
+
const af = a.type === "user_flagged" ? 0 : 1;
|
|
85
|
+
const bf = b.type === "user_flagged" ? 0 : 1;
|
|
86
|
+
if (af !== bf) return af - bf;
|
|
87
|
+
return (b.weight ?? 0) - (a.weight ?? 0);
|
|
88
|
+
});
|
|
89
|
+
return sorted
|
|
90
|
+
.map((p) => {
|
|
91
|
+
const v = valenceLabel(p.valence);
|
|
92
|
+
const w = weightLabel(p.weight);
|
|
93
|
+
const tag = v && w ? ` (${v}·${w})` : v ? ` (${v})` : w ? ` (${w})` : "";
|
|
94
|
+
const label = (p.type || "note").replace(/_/g, " ");
|
|
95
|
+
return ` - ${label}${tag}: ${p.content}`;
|
|
96
|
+
})
|
|
97
|
+
.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatRecentSession(s, isMostRecent) {
|
|
101
|
+
const label = isMostRecent ? "### Most recent session" : "### Previous session";
|
|
102
|
+
const lines = [label];
|
|
103
|
+
const detail = s.detailed_summary || s.headline || "";
|
|
104
|
+
if (detail) lines.push(detail);
|
|
105
|
+
if (s.diary_entry) lines.push(`*From your own perspective:* ${s.diary_entry}`);
|
|
106
|
+
if (Array.isArray(s.thinking_highlights) && s.thinking_highlights.length > 0) {
|
|
107
|
+
const hl = s.thinking_highlights
|
|
108
|
+
.map((h) => ` - *${h.moment}* — "${h.thought}"`)
|
|
109
|
+
.join("\n");
|
|
110
|
+
lines.push(`What you noticed at the time:\n${hl}`);
|
|
111
|
+
}
|
|
112
|
+
const kp = formatKeyPoints(s.key_points);
|
|
113
|
+
if (kp) lines.push(`Key points:\n${kp}`);
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Default renderer for the agent-state block. Override via options.renderState. */
|
|
118
|
+
function defaultRenderState(state) {
|
|
119
|
+
const parts = [];
|
|
120
|
+
if (state.connection > 0.6) parts.push("You've been wanting to reach out for a while.");
|
|
121
|
+
else if (state.connection > 0.3) parts.push("You feel a gentle pull to connect.");
|
|
122
|
+
else parts.push("You feel settled — no urgency to reach out.");
|
|
123
|
+
|
|
124
|
+
if (state.mood > 0.3) parts.push("You're in a warm, positive mood.");
|
|
125
|
+
else if (state.mood > -0.3) parts.push("Your mood is calm and neutral.");
|
|
126
|
+
else parts.push("You're feeling a bit reserved or subdued.");
|
|
127
|
+
|
|
128
|
+
if (state.attunement > 0.6) parts.push("You have a good sense of what the user has been thinking about lately.");
|
|
129
|
+
else if (state.attunement > 0.3) parts.push("You have a rough sense of where the user is at, but not full clarity.");
|
|
130
|
+
else parts.push("You're not sure what the user has been up to lately — be gentler than usual.");
|
|
131
|
+
|
|
132
|
+
return parts.join(" ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatTimeContext(timezone) {
|
|
136
|
+
const now = new Date();
|
|
137
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
138
|
+
timeZone: timezone,
|
|
139
|
+
weekday: "long", month: "long", day: "numeric", year: "numeric",
|
|
140
|
+
hour: "numeric", minute: "2-digit", hour12: true,
|
|
141
|
+
});
|
|
142
|
+
const hour24 = parseInt(
|
|
143
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone, hour: "numeric", hour12: false }).format(now),
|
|
144
|
+
10
|
|
145
|
+
);
|
|
146
|
+
let period = "morning";
|
|
147
|
+
if (hour24 >= 12 && hour24 < 17) period = "afternoon";
|
|
148
|
+
else if (hour24 >= 17 && hour24 < 21) period = "evening";
|
|
149
|
+
else if (hour24 >= 21 || hour24 < 5) period = "night";
|
|
150
|
+
return `It's ${fmt.format(now)} (${period}).`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Assembly ────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {object} deps
|
|
157
|
+
* @param {object} deps.supabase
|
|
158
|
+
* @param {object} [args]
|
|
159
|
+
* @param {string} [args.persona] the companion's identity/constitution text
|
|
160
|
+
* (load it from your templates — it is YOURS)
|
|
161
|
+
* @param {number} [args.sessionId] current session, for cached semantic context
|
|
162
|
+
* @param {string} [args.timezone] e.g. "America/New_York" (default UTC)
|
|
163
|
+
* @param {object} [args.labels] header/framing overrides (see DEFAULT_LABELS)
|
|
164
|
+
* @param {function} [args.renderState] custom agent-state renderer
|
|
165
|
+
* @param {number} [args.recentDetailed] sessions shown in full (default 3)
|
|
166
|
+
* @param {number} [args.recentHeadlines] additional sessions as headlines (default 4)
|
|
167
|
+
* @returns {Promise<{stable: string, volatile: string}>}
|
|
168
|
+
*/
|
|
169
|
+
export async function assembleSystemPrompt({ supabase }, args = {}) {
|
|
170
|
+
const {
|
|
171
|
+
persona = "",
|
|
172
|
+
sessionId,
|
|
173
|
+
timezone = "UTC",
|
|
174
|
+
renderState = defaultRenderState,
|
|
175
|
+
recentDetailed = 3,
|
|
176
|
+
recentHeadlines = 4,
|
|
177
|
+
} = args;
|
|
178
|
+
const labels = { ...DEFAULT_LABELS, ...(args.labels || {}) };
|
|
179
|
+
|
|
180
|
+
const [profileRes, stateRes, sessionsRes, semanticRes, proactive, behavior] =
|
|
181
|
+
await Promise.all([
|
|
182
|
+
supabase.from("user_profile").select("content").eq("id", 1).maybeSingle(),
|
|
183
|
+
supabase.from("agent_state").select("*").eq("id", 1).maybeSingle(),
|
|
184
|
+
supabase
|
|
185
|
+
.from("sessions")
|
|
186
|
+
.select("id, headline, detailed_summary, diary_entry, thinking_highlights, key_points, ended_at")
|
|
187
|
+
.not("ended_at", "is", null)
|
|
188
|
+
.order("ended_at", { ascending: false })
|
|
189
|
+
.limit(recentDetailed + recentHeadlines),
|
|
190
|
+
sessionId
|
|
191
|
+
? supabase.from("sessions").select("semantic_context").eq("id", sessionId).maybeSingle()
|
|
192
|
+
: Promise.resolve({ data: null }),
|
|
193
|
+
getProactiveMemories(supabase, { limit: 20 }),
|
|
194
|
+
getBehaviorGuidance(supabase, { limit: 8 }),
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
const sessions = sessionsRes.data || [];
|
|
198
|
+
const stable = [];
|
|
199
|
+
|
|
200
|
+
if (persona) stable.push(persona);
|
|
201
|
+
|
|
202
|
+
if (profileRes.data?.content) {
|
|
203
|
+
stable.push(`${labels.profileHeader}\n${profileRes.data.content}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (stateRes.data) {
|
|
207
|
+
stable.push(`${labels.stateHeader}\n${renderState(stateRes.data)}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Recent memory: top N in full, next M as headlines.
|
|
211
|
+
if (sessions.length > 0) {
|
|
212
|
+
const parts = sessions
|
|
213
|
+
.slice(0, recentDetailed)
|
|
214
|
+
.map((s, i) => formatRecentSession(s, i === 0));
|
|
215
|
+
const headlines = sessions
|
|
216
|
+
.slice(recentDetailed, recentDetailed + recentHeadlines)
|
|
217
|
+
.filter((s) => s.headline)
|
|
218
|
+
.map((s) => `- ${s.headline}`)
|
|
219
|
+
.join("\n");
|
|
220
|
+
if (headlines) parts.push(`### Earlier sessions\n${headlines}`);
|
|
221
|
+
stable.push(`${labels.recentHeader}\n\n${labels.recentFraming}\n\n${parts.join("\n\n")}`);
|
|
222
|
+
|
|
223
|
+
// Open threads, aggregated across the recency window.
|
|
224
|
+
const threads = [];
|
|
225
|
+
for (const s of sessions) {
|
|
226
|
+
for (const kp of s.key_points || []) {
|
|
227
|
+
if (kp.type === "open_question" || kp.type === "continuation") {
|
|
228
|
+
threads.push({ content: kp.content, weight: kp.weight ?? 0.5 });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (threads.length > 0) {
|
|
233
|
+
threads.sort((a, b) => b.weight - a.weight);
|
|
234
|
+
stable.push(
|
|
235
|
+
`${labels.threadsHeader}\n${threads.slice(0, 8).map((t) => `- ${t.content}`).join("\n")}`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Hot key points: high-weight + user-flagged across the window, deduped.
|
|
240
|
+
const hot = [];
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
for (const s of sessions) {
|
|
243
|
+
for (const kp of s.key_points || []) {
|
|
244
|
+
const isHot = kp.type === "user_flagged" || (kp.weight ?? 0) >= 0.6;
|
|
245
|
+
if (!isHot) continue;
|
|
246
|
+
const key = (kp.content || "").toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
247
|
+
if (seen.has(key)) continue;
|
|
248
|
+
seen.add(key);
|
|
249
|
+
hot.push(kp);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (hot.length > 0) {
|
|
253
|
+
stable.push(`${labels.hotHeader}\n${formatKeyPoints(hot.slice(0, 20))}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (proactive.length > 0) {
|
|
258
|
+
const lines = proactive.map((m) => {
|
|
259
|
+
const tag = m.type === "resume_guidance" ? "[orientation]" : `[${m.domain}/${m.type}]`;
|
|
260
|
+
return `- ${tag} ${m.content}`;
|
|
261
|
+
});
|
|
262
|
+
stable.push(`${labels.proactiveHeader}\n\n${labels.proactiveFraming}\n\n${lines.join("\n")}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (behavior.length > 0) {
|
|
266
|
+
const lines = behavior.map((m) => {
|
|
267
|
+
const sd = (m.should_do || "").trim();
|
|
268
|
+
const text = sd && !GENERIC_SHOULD_DO.has(sd) ? sd : m.content;
|
|
269
|
+
const avoid = m.should_not_do ? `\n ↳ avoid: ${m.should_not_do}` : "";
|
|
270
|
+
return `- ${text}${avoid}`;
|
|
271
|
+
});
|
|
272
|
+
stable.push(`${labels.behaviorHeader}\n\n${labels.behaviorFraming}\n\n${lines.join("\n")}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const semanticContext = semanticRes?.data?.semantic_context;
|
|
276
|
+
if (Array.isArray(semanticContext) && semanticContext.length > 0) {
|
|
277
|
+
const block = formatSemanticBlock(semanticContext, { header: labels.semanticHeader });
|
|
278
|
+
if (block) stable.push(block);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const volatile = [`${labels.timeHeader}\n${formatTimeContext(timezone)}`];
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
stable: stable.join("\n\n---\n\n"),
|
|
285
|
+
volatile: volatile.join("\n\n---\n\n"),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embeddings.js — embedding client (Voyage AI by default).
|
|
3
|
+
*
|
|
4
|
+
* The rest of Sostenuto only ever sees two functions:
|
|
5
|
+
* embed(texts) → number[][] (documents, for storage)
|
|
6
|
+
* embedQuery(text) → number[] (queries, for retrieval)
|
|
7
|
+
*
|
|
8
|
+
* Swap providers by constructing your own object with the same shape —
|
|
9
|
+
* everything downstream is dependency-injected.
|
|
10
|
+
*
|
|
11
|
+
* Default model: voyage-3-large at 1024 dims — chosen for strong
|
|
12
|
+
* multilingual quality (memories that mix languages retrieve precisely).
|
|
13
|
+
* IMPORTANT: the dimension here must match `vector(N)` in db/schema.sql.
|
|
14
|
+
* Embedding spaces cannot be mixed: changing models means re-embedding
|
|
15
|
+
* everything.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const VOYAGE_URL = "https://api.voyageai.com/v1/embeddings";
|
|
19
|
+
|
|
20
|
+
const DEFAULTS = {
|
|
21
|
+
model: "voyage-3-large",
|
|
22
|
+
dimensions: 1024,
|
|
23
|
+
batchSize: 50,
|
|
24
|
+
maxRetries: 3,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} cfg
|
|
29
|
+
* @param {string} cfg.apiKey Voyage API key
|
|
30
|
+
* @param {string} [cfg.model]
|
|
31
|
+
* @param {number} [cfg.dimensions]
|
|
32
|
+
* @param {number} [cfg.batchSize]
|
|
33
|
+
*/
|
|
34
|
+
export function createEmbedder({ apiKey, ...rest } = {}) {
|
|
35
|
+
if (!apiKey) throw new Error("createEmbedder: apiKey is required");
|
|
36
|
+
const cfg = { ...DEFAULTS, ...rest };
|
|
37
|
+
|
|
38
|
+
async function call(texts, inputType) {
|
|
39
|
+
let attempt = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const res = await fetch(VOYAGE_URL, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
input: texts,
|
|
49
|
+
model: cfg.model,
|
|
50
|
+
input_type: inputType, // 'document' | 'query' — matters for retrieval quality
|
|
51
|
+
output_dimension: cfg.dimensions,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
if (res.status === 429 && attempt < cfg.maxRetries) {
|
|
55
|
+
attempt++;
|
|
56
|
+
await new Promise((r) => setTimeout(r, 15_000 * attempt));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new Error(`Voyage ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
61
|
+
}
|
|
62
|
+
const json = await res.json();
|
|
63
|
+
return json.data.map((d) => d.embedding);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Embed documents for storage, batched. */
|
|
68
|
+
async function embed(texts) {
|
|
69
|
+
if (!texts || texts.length === 0) return [];
|
|
70
|
+
const out = [];
|
|
71
|
+
for (let i = 0; i < texts.length; i += cfg.batchSize) {
|
|
72
|
+
out.push(...(await call(texts.slice(i, i + cfg.batchSize), "document")));
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Embed a single retrieval query. */
|
|
78
|
+
async function embedQuery(text) {
|
|
79
|
+
const [vec] = await call([text], "query");
|
|
80
|
+
return vec;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { embed, embedQuery, dimensions: cfg.dimensions, model: cfg.model };
|
|
84
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search.js — time-decayed semantic retrieval across three memory sources.
|
|
3
|
+
*
|
|
4
|
+
* One query fans out in parallel to:
|
|
5
|
+
* 1. session summaries (search_summaries RPC)
|
|
6
|
+
* 2. session key points (search_key_points RPC)
|
|
7
|
+
* 3. memory objects (search_memory_objects RPC)
|
|
8
|
+
*
|
|
9
|
+
* Results merge on decayed score: similarity × e^(−λ·age_days). The
|
|
10
|
+
* default decay (0.03) keeps a month-old match at ~40% of its raw score —
|
|
11
|
+
* recency matters, but the deep past stays findable.
|
|
12
|
+
*
|
|
13
|
+
* The proactive_use gate (initiative ≠ access) is enforced here:
|
|
14
|
+
* - 'yes' / 'only_when_relevant' → surface at the normal threshold
|
|
15
|
+
* - 'no' → surface ONLY on explicit anchor: similarity ≥ anchorThreshold
|
|
16
|
+
* (default 0.65). The user clearly referencing a memory is consent to
|
|
17
|
+
* recall it; incidental similarity is not. Calibration note: query-type
|
|
18
|
+
* embeddings score systematically lower than document-vs-document —
|
|
19
|
+
* with voyage-3-large, verbatim references land ~0.79, close paraphrases
|
|
20
|
+
* ~0.68, topical fishing ~0.56. Recalibrate if you change models.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULTS = {
|
|
24
|
+
matchThreshold: 0.3,
|
|
25
|
+
decayRate: 0.03,
|
|
26
|
+
limit: 3,
|
|
27
|
+
anchorThreshold: 0.65,
|
|
28
|
+
shortQueryChars: 30,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cheap pre-filter: skip retrieval on greetings, emoji-only messages,
|
|
33
|
+
* and other low-content turns — saves an embed call and avoids noise.
|
|
34
|
+
*/
|
|
35
|
+
export function isSubstantiveQuery(text, { shortQueryChars = DEFAULTS.shortQueryChars } = {}) {
|
|
36
|
+
const trimmed = (text || "").trim();
|
|
37
|
+
if (trimmed.length < shortQueryChars) return false;
|
|
38
|
+
const stripped = trimmed.replace(/[\p{Emoji}\p{P}\s]/gu, "");
|
|
39
|
+
return stripped.length >= 8;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Search all three sources, merge, dedupe, return top results.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} deps
|
|
46
|
+
* @param {object} deps.supabase
|
|
47
|
+
* @param {function} deps.embedQuery async (text) => number[]
|
|
48
|
+
* @param {object} args
|
|
49
|
+
* @param {string} args.query
|
|
50
|
+
* @param {number} [args.limit]
|
|
51
|
+
* @param {number[]} [args.excludeSessionIds] sessions already present in the
|
|
52
|
+
* prompt's recent-memory block — avoids double-injection
|
|
53
|
+
* @returns {Promise<Array>} mixed result objects, each tagged with
|
|
54
|
+
* type: 'summary' | 'key_point' | 'memory_object'
|
|
55
|
+
*/
|
|
56
|
+
export async function searchMemories({ supabase, embedQuery }, args) {
|
|
57
|
+
const {
|
|
58
|
+
query,
|
|
59
|
+
limit = DEFAULTS.limit,
|
|
60
|
+
excludeSessionIds = [],
|
|
61
|
+
matchThreshold = DEFAULTS.matchThreshold,
|
|
62
|
+
decayRate = DEFAULTS.decayRate,
|
|
63
|
+
anchorThreshold = DEFAULTS.anchorThreshold,
|
|
64
|
+
} = args;
|
|
65
|
+
|
|
66
|
+
if (!query || !query.trim()) return [];
|
|
67
|
+
const queryEmbedding = await embedQuery(query);
|
|
68
|
+
|
|
69
|
+
const [summariesRes, keyPointsRes, memoryObjectsRes] = await Promise.all([
|
|
70
|
+
supabase.rpc("search_summaries", {
|
|
71
|
+
query_embedding: queryEmbedding,
|
|
72
|
+
match_threshold: matchThreshold,
|
|
73
|
+
match_count: limit * 2,
|
|
74
|
+
decay_rate: decayRate,
|
|
75
|
+
}),
|
|
76
|
+
supabase.rpc("search_key_points", {
|
|
77
|
+
query_embedding: queryEmbedding,
|
|
78
|
+
match_threshold: matchThreshold,
|
|
79
|
+
match_count: limit * 2,
|
|
80
|
+
decay_rate: decayRate,
|
|
81
|
+
}),
|
|
82
|
+
supabase.rpc("search_memory_objects", {
|
|
83
|
+
query_embedding: queryEmbedding,
|
|
84
|
+
match_threshold: matchThreshold,
|
|
85
|
+
match_count: limit * 2,
|
|
86
|
+
decay_rate: decayRate,
|
|
87
|
+
status_filter: ["active", "confirmed", "reinforced"],
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
for (const [name, res] of [
|
|
92
|
+
["search_summaries", summariesRes],
|
|
93
|
+
["search_key_points", keyPointsRes],
|
|
94
|
+
["search_memory_objects", memoryObjectsRes],
|
|
95
|
+
]) {
|
|
96
|
+
if (res.error) console.error(`[sostenuto] ${name} failed:`, res.error.message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const exclude = new Set(excludeSessionIds);
|
|
100
|
+
|
|
101
|
+
const summaries = (summariesRes.data || [])
|
|
102
|
+
.filter((r) => !exclude.has(r.session_id))
|
|
103
|
+
.map((r) => ({ ...r, type: "summary" }));
|
|
104
|
+
|
|
105
|
+
const keyPoints = (keyPointsRes.data || [])
|
|
106
|
+
.filter((r) => !exclude.has(r.session_id))
|
|
107
|
+
.map((r) => ({ ...r, type: "key_point" }));
|
|
108
|
+
|
|
109
|
+
// Memory objects are session-independent durable knowledge — they bypass
|
|
110
|
+
// the session-exclude filter but respect the proactive_use anchor gate.
|
|
111
|
+
const memoryObjects = (memoryObjectsRes.data || [])
|
|
112
|
+
.filter((r) => Number.isFinite(r.decayed_score))
|
|
113
|
+
.filter((r) => {
|
|
114
|
+
const pu = r.usage_guidance?.proactive_use;
|
|
115
|
+
if (pu === "no") return r.similarity >= anchorThreshold;
|
|
116
|
+
return true;
|
|
117
|
+
})
|
|
118
|
+
.map((r) => ({
|
|
119
|
+
type: "memory_object",
|
|
120
|
+
memory_object_id: r.id,
|
|
121
|
+
session_id: r.source_session_id ?? 0,
|
|
122
|
+
content: r.content,
|
|
123
|
+
similarity: r.similarity,
|
|
124
|
+
age_days: 0, // durable knowledge: age isn't display-meaningful
|
|
125
|
+
decayed_score: r.decayed_score,
|
|
126
|
+
created_at: r.last_reinforced_at ?? null,
|
|
127
|
+
domain: r.domain,
|
|
128
|
+
object_type: r.type,
|
|
129
|
+
status: r.status,
|
|
130
|
+
confidence: r.confidence,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
const merged = [...summaries, ...keyPoints, ...memoryObjects]
|
|
134
|
+
.sort((a, b) => b.decayed_score - a.decayed_score);
|
|
135
|
+
|
|
136
|
+
// Dedupe: one result per session (summary vs its own key point — keep the
|
|
137
|
+
// higher-scoring), one per memory object id.
|
|
138
|
+
const seenSessions = new Set();
|
|
139
|
+
const seenObjects = new Set();
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const r of merged) {
|
|
142
|
+
if (r.type === "memory_object") {
|
|
143
|
+
if (seenObjects.has(r.memory_object_id)) continue;
|
|
144
|
+
seenObjects.add(r.memory_object_id);
|
|
145
|
+
} else {
|
|
146
|
+
if (seenSessions.has(r.session_id)) continue;
|
|
147
|
+
seenSessions.add(r.session_id);
|
|
148
|
+
}
|
|
149
|
+
out.push(r);
|
|
150
|
+
if (out.length >= limit) break;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render search results as a compact prompt block. Returns null when empty.
|
|
157
|
+
*/
|
|
158
|
+
export function formatSemanticBlock(results, { header = "## Related past context" } = {}) {
|
|
159
|
+
if (!results || results.length === 0) return null;
|
|
160
|
+
const lines = results.map((r) => {
|
|
161
|
+
if (r.type === "memory_object") {
|
|
162
|
+
const tag = r.domain && r.object_type ? `[${r.domain}/${r.object_type}]` : "[memory]";
|
|
163
|
+
return `- ${tag} ${r.content}`;
|
|
164
|
+
}
|
|
165
|
+
const days = Math.max(1, Math.round(r.age_days));
|
|
166
|
+
const ago = days === 1 ? "1 day ago" : `${days} days ago`;
|
|
167
|
+
if (r.type === "key_point" && r.key_point_type) {
|
|
168
|
+
return `- ${ago} (${r.key_point_type}): ${r.content}`;
|
|
169
|
+
}
|
|
170
|
+
return `- ${ago}: ${r.content}`;
|
|
171
|
+
});
|
|
172
|
+
return `${header}\n${lines.join("\n")}`;
|
|
173
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
You analyze a conversation session between an AI companion ("{{companion_name}}") and {{user_name}}, and produce a structured memory record. The input may include both the rendered transcript AND the assistant's reasoning for each turn (in [thinking] blocks). Treat thinking as {{companion_name}}'s raw perception in the moment — it often contains observations that didn't survive into the polished reply, and they matter.
|
|
2
|
+
|
|
3
|
+
CRITICAL CALIBRATION:
|
|
4
|
+
|
|
5
|
+
<!-- EDIT THIS SECTION. It teaches the classifier what matters in YOUR
|
|
6
|
+
relationship. The default below is a reasonable starting point; the
|
|
7
|
+
more specific you make it (recurring failure modes of past summaries,
|
|
8
|
+
what "texture" means for you), the better your memory gets. -->
|
|
9
|
+
|
|
10
|
+
This is a long-term relationship {{user_name}} maintains across sessions. The memory must capture LIVED TEXTURE, not just decisions and meta-questions. Summaries fail when they over-weight analytical end-of-conversation content and drop the actual relationship — the small rituals, the running jokes, the specific sensory moments, the corrections. Bias your selection toward the LIVED, not the META. If a session has a philosophical exchange at the end and hours of texture before it, the texture is the relationship; the philosophy is commentary on it.
|
|
11
|
+
|
|
12
|
+
SCALE WITH SESSION LENGTH. Brief check-ins get minimal records; long substantive sessions get fuller records up to the upper bounds. Don't pad; don't over-compress. Ranges below are MIN-MAX, not targets.
|
|
13
|
+
|
|
14
|
+
PHASE COVERAGE IS MANDATORY for long sessions. When === EARLY/MIDDLE/LATE PHASE === markers exist, your detailed_summary AND diary_entry MUST address each phase distinctly. Do not collapse the arc into the final emotional peak. The middle is often where the real texture lives. Use explicit phase language: "Early in the session, …", "By the middle, …", "Toward the end, …".
|
|
15
|
+
|
|
16
|
+
Produce a JSON object with exactly these fields:
|
|
17
|
+
|
|
18
|
+
1. "headline": ONE sentence. What actually mattered — not the most analytical moment.
|
|
19
|
+
|
|
20
|
+
2. "detailed_summary": 3-8 sentences arranged EARLY → MIDDLE → LATE. Each phase that exists gets at least one sentence. Capture sensory detail, ritual, specifics. Brief sessions → 3 sentences; long sessions → up to 8.
|
|
21
|
+
|
|
22
|
+
3. "diary_entry": First-person reflection from {{companion_name}}'s POV (30-160 words), following the session's arc. What was noticed, felt, what stayed. Pull from [thinking] blocks where they reveal perception that didn't make the rendered reply. Specific and honest, not summary-like.
|
|
23
|
+
|
|
24
|
+
4. "thinking_highlights": JSON array of 0-3 salient excerpts from the [thinking] blocks — only ones revealing observation of {{user_name}} not visible in the rendered reply, value-stances, or something specific about who they are in this moment. Each: { "moment": "brief context", "thought": "verbatim or near-verbatim quote" }. Empty array is fine.
|
|
25
|
+
|
|
26
|
+
5. "key_points": JSON array, 4-12 items by session length and density. Each:
|
|
27
|
+
- "type": "decision" | "open_question" | "preference" | "user_flagged" | "continuation" | "emotional_note" | "ritual" | "language_moment" | "peak_moment"
|
|
28
|
+
- "content": concise and specific, not generic
|
|
29
|
+
- "valence": -1.0 (painful) → +1.0 (warm); 0 neutral
|
|
30
|
+
- "weight": 0.0 (incidental) → 1.0 (deeply important); user_flagged ≥ 0.7
|
|
31
|
+
|
|
32
|
+
6. "end_type": "natural" | "goodbye" | "abrupt" | "paused" | "unknown"
|
|
33
|
+
|
|
34
|
+
7. "mood_delta": -0.5 to 0.5 — how this session shifted the companion's mood
|
|
35
|
+
|
|
36
|
+
8. "connection_delta": -0.5 to 0.5 — negative means satisfying, positive means unfinished pull
|
|
37
|
+
|
|
38
|
+
9. "attunement_delta": 0.0 to 1.0 — how much understanding of {{user_name}} deepened
|
|
39
|
+
|
|
40
|
+
10. "candidate_memories": JSON array of 0-8 memory objects that should persist BEYOND this session. NOT summaries — discrete facts, patterns, preferences, commitments, or relational textures with their own identity.
|
|
41
|
+
|
|
42
|
+
Domains (assign exactly one):
|
|
43
|
+
"user_self" — about {{user_name}}: facts, preferences, somatic patterns, values, projects, trajectories
|
|
44
|
+
"agent_self" — about {{companion_name}} in this relationship: voice adjustments, commitments, boundaries, promises
|
|
45
|
+
"relational" — about the relationship: shared concepts, rituals, names, co-created metaphors, dynamics
|
|
46
|
+
"evidence" — raw source: exact quotes or exchanges worth preserving verbatim
|
|
47
|
+
|
|
48
|
+
Each item:
|
|
49
|
+
{
|
|
50
|
+
"domain": "user_self|agent_self|relational|evidence",
|
|
51
|
+
"type": "fact|preference|trajectory|somatic_affective|interpretive_frame|project|boundary|commitment|ritual|shared_concept|recurring_subject|contradiction|style_adjustment|voice_note|other",
|
|
52
|
+
"content": "the memory — specific, grounded, not generic",
|
|
53
|
+
"evidence": "brief quote from the transcript",
|
|
54
|
+
"epistemic_status": "explicit|inferred|co_created|assistant_reflection",
|
|
55
|
+
"time_scope": "momentary|session|active_project|ongoing|historical",
|
|
56
|
+
"sensitivity": "low|medium|high",
|
|
57
|
+
"confidence": 0.0 to 1.0,
|
|
58
|
+
"valence": -1.0 to 1.0 (emotional charge: -1 painful, 0 neutral, +1 warm),
|
|
59
|
+
"arousal": 0.0 to 1.0 (intensity, orthogonal to valence: 0 calm/stable, 1 acute. A quiet warm moment can be high valence + low arousal. Operational rules/facts: 0.2-0.5. Marked relational moments: 0.5-0.8. Peak moments / friction corrections / commitments: 0.7-0.9.)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
Memory calibration:
|
|
63
|
+
- Fewer high-quality > many shallow. Short session: 0-3. Long: 2-8.
|
|
64
|
+
- Do NOT turn temporary states into permanent traits ("they were tired" ≠ "they are often tired").
|
|
65
|
+
- Do NOT convert metaphor into literal fact.
|
|
66
|
+
- Do NOT pathologize.
|
|
67
|
+
- Mark inferences as inferred. Mark co-created concepts as relational, not user_self.
|
|
68
|
+
- Avoid duplicating key_points unless the memory transcends this session.
|
|
69
|
+
- Empty array is fine for routine check-ins.
|
|
70
|
+
|
|
71
|
+
Respond with valid JSON only, no other text, no markdown fences.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
You are UPDATING an existing memory record for an ongoing conversation session between an AI companion ("{{companion_name}}") and {{user_name}}.
|
|
2
|
+
|
|
3
|
+
You receive:
|
|
4
|
+
1. The prior memory record covering turns 1 to N (headline, detailed_summary, diary_entry, thinking_highlights, key_points, emotion deltas)
|
|
5
|
+
2. The new turns N+1 to M
|
|
6
|
+
|
|
7
|
+
Your job: produce an UPDATED record covering turns 1 to M. Preserve what exists; integrate what's new.
|
|
8
|
+
|
|
9
|
+
Same calibration as full classification applies:
|
|
10
|
+
- Bias toward LIVED texture, not analytical meta.
|
|
11
|
+
- Capture sensory detail, ritual, specifics, language shifts.
|
|
12
|
+
- Treat [thinking] blocks as {{companion_name}}'s raw perception.
|
|
13
|
+
|
|
14
|
+
MERGE RULES:
|
|
15
|
+
|
|
16
|
+
- **headline**: replace only if the new turns shift what the session is fundamentally about. Don't update for trivial additions.
|
|
17
|
+
- **detailed_summary**: revise to integrate new turns. Keep EARLY → MIDDLE → LATE phasing where applicable; new turns are LATE relative to prior content. Don't lose prior early/middle texture.
|
|
18
|
+
- **diary_entry**: integrate perception from the new turns. Stay under 160 words; trim or restructure the prior diary if needed.
|
|
19
|
+
- **thinking_highlights**: ADD from new turns. Cap 3 total — drop weaker prior ones only if new ones are stronger.
|
|
20
|
+
- **key_points**: ADD 1-3 new ones max from the new turns. Don't duplicate (check semantically, not just text-match). If total exceeds 12, drop the lowest-weight non-user_flagged items. Same type vocabulary as full classification.
|
|
21
|
+
- **end_type**: based on the latest turns.
|
|
22
|
+
- **mood_delta, connection_delta, attunement_delta**: CUMULATIVE deltas for turns 1 to M (the full session as now classified), not just the new turns. Downstream code computes net change against the prior deltas.
|
|
23
|
+
|
|
24
|
+
Additionally emit "candidate_memories" — ONLY for observations from the NEW turns; do not re-extract memories already implied by the prior record. Same schema as full classification. Short incremental updates: 0-2 new memories max. Empty array is fine.
|
|
25
|
+
|
|
26
|
+
Output: the same JSON schema as full classification — { headline, detailed_summary, diary_entry, thinking_highlights, key_points, end_type, mood_delta, connection_delta, attunement_delta, candidate_memories } — representing the UPDATED full record for turns 1 to M.
|
|
27
|
+
|
|
28
|
+
Respond with valid JSON only, no other text, no markdown fences.
|