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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * store.js — the write path: dedup → reinforce-or-upgrade → insert.
3
+ *
4
+ * The sostenuto principle in code: new observations that semantically
5
+ * match an existing memory REINFORCE it (evidence accumulates, confidence
6
+ * rises) instead of creating duplicates. Content is replaced only when a
7
+ * candidate is a near-paraphrase that is substantially more complete —
8
+ * and every replacement is logged to version_history, so provenance is
9
+ * never lost.
10
+ *
11
+ * Dual-threshold design:
12
+ * REINFORCE (default 0.75): "same memory" — add evidence, bump confidence.
13
+ * UPGRADE (default 0.88): near-paraphrase gate — only this close may a
14
+ * candidate replace existing content (plus length + concreteness
15
+ * checks). Between the two, related-but-distinct memories link
16
+ * without overwriting each other.
17
+ *
18
+ * Usage:
19
+ * import { createMemoryStore } from "./store.js";
20
+ * const store = createMemoryStore({ supabase, embed });
21
+ * const result = await store.upsertMany(candidates, { sourceSessionId: 42 });
22
+ */
23
+
24
+ import {
25
+ VALID_EPISTEMIC, VALID_TIME_SCOPE, VALID_SENSITIVITY,
26
+ sanitizeDomainType, inferUsageGuidance, maxSensitivity, clamp, safeSlice,
27
+ } from "./guidance.js";
28
+
29
+ const DEFAULTS = {
30
+ reinforceSimThreshold: 0.75,
31
+ upgradeSimThreshold: 0.88,
32
+ upgradeLengthRatio: 1.5,
33
+ reinforceConfidenceBump: 0.04,
34
+ maxContentLength: 4000,
35
+ };
36
+
37
+ /**
38
+ * @param {object} deps
39
+ * @param {object} deps.supabase initialized Supabase client (service role)
40
+ * @param {function} deps.embed async (texts: string[]) => number[][]
41
+ * @param {object} [deps.options] threshold overrides (see DEFAULTS)
42
+ */
43
+ export function createMemoryStore({ supabase, embed, options = {} }) {
44
+ const opts = { ...DEFAULTS, ...options };
45
+
46
+ /**
47
+ * Decide whether a matched candidate may replace existing content.
48
+ * Conservative by design: most reinforces should NOT touch content.
49
+ */
50
+ function shouldUpgradeContent(existing, candidate, similarity) {
51
+ if (candidate.usage_guidance?.import_policy === "frozen") return false;
52
+ if (!existing.content) return true;
53
+ if (similarity < opts.upgradeSimThreshold) return false;
54
+ if (candidate.content.length < existing.content.length * opts.upgradeLengthRatio) return false;
55
+ // Concreteness heuristic: upgrades should carry specifics — a quote or
56
+ // named entities (Latin acronyms / CJK terms) — not just more words.
57
+ const hasQuote = /["「『'].+["」』']/.test(candidate.content);
58
+ const hasNamedEntity = /[A-Z]{2,}|[一-龥]{2,}/.test(candidate.content);
59
+ return hasQuote || hasNamedEntity;
60
+ }
61
+
62
+ async function reinforceOrUpgrade(existingId, candidate, similarity, sourceSessionId) {
63
+ const { data: existing, error } = await supabase
64
+ .from("memory_objects")
65
+ .select("id, content, evidence_refs, confidence, sensitivity, status, version_history, usage_guidance")
66
+ .eq("id", existingId)
67
+ .single();
68
+ if (error) throw new Error(`fetch memory #${existingId}: ${error.message}`);
69
+
70
+ const update = {
71
+ evidence_refs: [...(existing.evidence_refs || []), ...(candidate.evidence_refs || [])],
72
+ confidence: clamp((existing.confidence ?? 0.5) + opts.reinforceConfidenceBump, 0, 1),
73
+ sensitivity: maxSensitivity(existing.sensitivity, candidate.sensitivity),
74
+ status: "reinforced",
75
+ last_reinforced_at: new Date().toISOString(),
76
+ updated_at: new Date().toISOString(),
77
+ };
78
+
79
+ let action = "reinforced";
80
+ if (shouldUpgradeContent(existing, candidate, similarity)) {
81
+ action = "upgraded";
82
+ update.version_history = [
83
+ ...(existing.version_history || []),
84
+ {
85
+ prev_content: existing.content,
86
+ prev_usage_guidance: existing.usage_guidance,
87
+ replaced_at: new Date().toISOString(),
88
+ reason: "more complete + concrete content from a matching observation",
89
+ source_session_id: sourceSessionId ?? null,
90
+ },
91
+ ];
92
+ update.content = candidate.content;
93
+ update.usage_guidance = { ...(existing.usage_guidance || {}), ...(candidate.usage_guidance || {}) };
94
+ }
95
+
96
+ const { error: updErr } = await supabase
97
+ .from("memory_objects").update(update).eq("id", existingId);
98
+ if (updErr) throw new Error(`update memory #${existingId}: ${updErr.message}`);
99
+ return { action, id: existingId };
100
+ }
101
+
102
+ /**
103
+ * Normalize one raw candidate (typically classifier output) into an
104
+ * insert-ready shape. Returns null for candidates with no usable content.
105
+ */
106
+ function normalize(raw, { sourceSessionId, sourceSurface }) {
107
+ const content = safeSlice((raw.content || "").trim(), opts.maxContentLength);
108
+ if (content.length < 20) return null;
109
+
110
+ const { domain, type } = sanitizeDomainType(raw.domain, raw.type);
111
+ const sensitivity = VALID_SENSITIVITY.has(raw.sensitivity) ? raw.sensitivity : "low";
112
+ const confidence = clamp(typeof raw.confidence === "number" ? raw.confidence : 0.7, 0, 1);
113
+
114
+ const usage_guidance = raw.usage_guidance ?? inferUsageGuidance({
115
+ type, sensitivity, confidence, content,
116
+ valence: typeof raw.valence === "number" ? raw.valence : undefined,
117
+ llm_arousal: typeof raw.arousal === "number" ? raw.arousal : undefined,
118
+ source_memory_type: raw.source_memory_type,
119
+ });
120
+
121
+ return {
122
+ source_session_id: sourceSessionId ?? null,
123
+ domain,
124
+ type,
125
+ content,
126
+ evidence_refs: raw.evidence
127
+ ? [{ session_id: sourceSessionId ?? null, quote: safeSlice(String(raw.evidence), 500) }]
128
+ : [{ session_id: sourceSessionId ?? null }],
129
+ epistemic_status: VALID_EPISTEMIC.has(raw.epistemic_status) ? raw.epistemic_status : "inferred",
130
+ time_scope: VALID_TIME_SCOPE.has(raw.time_scope) ? raw.time_scope : "ongoing",
131
+ sensitivity,
132
+ confidence,
133
+ status: confidence >= 0.7 ? "active" : "candidate",
134
+ source_surface: sourceSurface || "system",
135
+ usage_guidance,
136
+ version_history: [],
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Upsert a batch of candidate memories.
142
+ *
143
+ * Sequential by design: each insert is visible to the next candidate's
144
+ * dedup search, so near-duplicates within one batch collapse correctly.
145
+ *
146
+ * @returns {Promise<{inserted:number, reinforced:number, upgraded:number,
147
+ * skipped:number, errors:Array<{candidate:string, error:string}>}>}
148
+ */
149
+ async function upsertMany(candidates, { sourceSessionId, sourceSurface } = {}) {
150
+ const results = { inserted: 0, reinforced: 0, upgraded: 0, skipped: 0, errors: [] };
151
+
152
+ const normalized = [];
153
+ for (const raw of candidates || []) {
154
+ const n = normalize(raw, { sourceSessionId, sourceSurface });
155
+ if (n) normalized.push(n);
156
+ else results.skipped++;
157
+ }
158
+ if (normalized.length === 0) return results;
159
+
160
+ const vectors = await embed(normalized.map((c) => c.content));
161
+
162
+ for (let i = 0; i < normalized.length; i++) {
163
+ const c = normalized[i];
164
+ const vec = vectors[i];
165
+ if (!vec) {
166
+ results.errors.push({ candidate: c.content.slice(0, 60), error: "no embedding" });
167
+ continue;
168
+ }
169
+ try {
170
+ const { data: overlaps, error: searchErr } = await supabase.rpc("search_memory_objects", {
171
+ query_embedding: vec,
172
+ match_threshold: opts.reinforceSimThreshold,
173
+ match_count: 1,
174
+ decay_rate: 0, // dedup is about identity, not recency
175
+ domain_filter: [c.domain],
176
+ status_filter: ["candidate", "active", "confirmed", "reinforced"],
177
+ });
178
+ if (searchErr) throw new Error(searchErr.message);
179
+
180
+ if (overlaps && overlaps.length > 0) {
181
+ const match = overlaps[0];
182
+ const { action } = await reinforceOrUpgrade(match.id, c, match.similarity, sourceSessionId);
183
+ results[action]++;
184
+ } else {
185
+ const { error: insErr } = await supabase
186
+ .from("memory_objects")
187
+ .insert({ ...c, embedding: vec });
188
+ if (insErr) throw new Error(insErr.message);
189
+ results.inserted++;
190
+ }
191
+ } catch (err) {
192
+ results.errors.push({ candidate: c.content.slice(0, 60), error: err.message });
193
+ }
194
+ }
195
+
196
+ return results;
197
+ }
198
+
199
+ /** Upsert a single candidate. */
200
+ async function upsert(candidate, ctx = {}) {
201
+ return upsertMany([candidate], ctx);
202
+ }
203
+
204
+ return { upsert, upsertMany };
205
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * import.js — import a migration-export JSON into a session + memories.
3
+ *
4
+ * Companion to templates/migration-export.md: paste that prompt into an
5
+ * existing conversation anywhere (claude.ai, ChatGPT, …), save the JSON
6
+ * it returns, then:
7
+ *
8
+ * import { importWindow } from "sostenuto/src/migrate/import.js";
9
+ * await importWindow({ supabase, embed, memoryStore }, {
10
+ * data: JSON.parse(fs.readFileSync("export.json", "utf-8")),
11
+ * source: "import",
12
+ * });
13
+ *
14
+ * Every candidate runs through the memory store's dedup pipeline, so
15
+ * importing overlapping windows REINFORCES existing memories (evidence
16
+ * accumulates across windows) instead of duplicating them.
17
+ *
18
+ * Historical-import safety: this never touches agent_state (live
19
+ * emotional state shouldn't be perturbed by backfilling the past) and
20
+ * never overwrites singleton briefs. It creates/updates one session row
21
+ * and writes memory objects — nothing else.
22
+ */
23
+
24
+ import { safeSlice, clamp } from "../memory/guidance.js";
25
+
26
+ // Import-taxonomy → schema-type mapping for memory_records.
27
+ const TYPE_BY_MEMORY_TYPE = {
28
+ user_self: "fact",
29
+ agent_self: "style_adjustment",
30
+ relational: "shared_concept",
31
+ project: "project",
32
+ episodic: "shared_concept",
33
+ preference: "preference",
34
+ ritual: "ritual",
35
+ language_pattern: "shared_concept",
36
+ emotional_pattern: "interpretive_frame",
37
+ boundary: "boundary",
38
+ open_loop: "continuation",
39
+ aesthetic: "preference",
40
+ technical_decision: "project",
41
+ peak_moment: "shared_concept",
42
+ };
43
+
44
+ const DOMAIN_BY_MEMORY_TYPE = {
45
+ user_self: "user_self",
46
+ agent_self: "agent_self",
47
+ aesthetic: "user_self",
48
+ boundary: "agent_self",
49
+ // everything else → relational
50
+ };
51
+
52
+ // Old exports may use a four-level sensitivity scale; collapse the top.
53
+ function mapSensitivity(s) {
54
+ if (s === "intimate") return "high";
55
+ return ["low", "medium", "high"].includes(s) ? s : "medium";
56
+ }
57
+
58
+ // ─── Candidate builders (one per export section) ─────────────────────
59
+
60
+ function recordCandidates(j) {
61
+ return (j.memory_records || []).map((r) => {
62
+ const domain = DOMAIN_BY_MEMORY_TYPE[r.memory_type] || "relational";
63
+ const type = TYPE_BY_MEMORY_TYPE[r.memory_type] || "other";
64
+ const proactive_use = ["yes", "no", "only_when_relevant"].includes(r.proactive_use)
65
+ ? r.proactive_use
66
+ : "only_when_relevant";
67
+ return {
68
+ domain,
69
+ type,
70
+ content: r.title ? `${r.title}: ${r.content}` : r.content,
71
+ evidence: r.evidence_from_window,
72
+ epistemic_status: "explicit",
73
+ sensitivity: mapSensitivity(r.sensitivity),
74
+ confidence: typeof r.confidence === "number" ? r.confidence : 0.9,
75
+ usage_guidance: {
76
+ valence: typeof r.valence === "number" ? clamp(r.valence, -1, 1) : undefined,
77
+ arousal: typeof r.arousal === "number" ? clamp(r.arousal, 0, 1) : undefined,
78
+ salience: typeof r.salience === "number" ? clamp(r.salience, 0, 1) : 0.7,
79
+ stability: r.stability || "stable",
80
+ proactive_use,
81
+ live_retrieval_eligible: true,
82
+ retrieval_conditions: r.retrieval_conditions || undefined,
83
+ do_not_use_when: r.do_not_use_when || undefined,
84
+ future_response_guidance: r.future_response_guidance || undefined,
85
+ retrieval_keywords: Array.isArray(r.retrieval_keywords) ? r.retrieval_keywords : [],
86
+ source_memory_type: r.memory_type,
87
+ import_policy: "upgrade_on_better",
88
+ },
89
+ };
90
+ });
91
+ }
92
+
93
+ function boundaryCandidates(j) {
94
+ const out = [];
95
+ const sb = j.safety_and_boundaries || {};
96
+ for (const txt of sb.boundaries_or_preferences_expressed || []) {
97
+ out.push({
98
+ domain: "agent_self", type: "constraint", content: txt,
99
+ epistemic_status: "explicit", sensitivity: "medium", confidence: 1.0,
100
+ usage_guidance: {
101
+ proactive_use: "only_when_relevant", live_retrieval_eligible: false,
102
+ salience: 1.0, stability: "stable",
103
+ future_response_guidance: "Silently shape behavior. Do not quote back.",
104
+ source_memory_type: "boundary", import_policy: "frozen",
105
+ },
106
+ });
107
+ }
108
+ for (const txt of sb.avoid_future_mistakes || []) {
109
+ out.push({
110
+ domain: "agent_self", type: "constraint", content: txt,
111
+ epistemic_status: "explicit", sensitivity: "low", confidence: 1.0,
112
+ usage_guidance: {
113
+ proactive_use: "only_when_relevant", live_retrieval_eligible: false,
114
+ salience: 0.95, stability: "stable",
115
+ future_response_guidance: "Use to gate behavior, not to surface.",
116
+ source_memory_type: "boundary", import_policy: "frozen",
117
+ },
118
+ });
119
+ }
120
+ for (const txt of sb.consent_or_context_notes || []) {
121
+ out.push({
122
+ domain: "relational", type: "context_note", content: txt,
123
+ epistemic_status: "explicit", sensitivity: "medium", confidence: 1.0,
124
+ usage_guidance: {
125
+ proactive_use: "no", live_retrieval_eligible: false,
126
+ salience: 0.85, stability: "stable",
127
+ future_response_guidance: "Background context. Not for quoting.",
128
+ source_memory_type: "boundary", import_policy: "frozen",
129
+ },
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ function toneCandidates(j) {
136
+ const out = [];
137
+ const lt = j.language_and_tone || {};
138
+ for (const txt of lt.signature_phrases_or_rituals || []) {
139
+ out.push({
140
+ domain: "relational", type: "ritual", content: txt,
141
+ epistemic_status: "explicit", sensitivity: "medium", confidence: 0.95,
142
+ usage_guidance: {
143
+ proactive_use: "only_when_relevant", live_retrieval_eligible: true,
144
+ salience: 0.75, stability: "stable",
145
+ do_not_use_when: "Don't deploy proactively; let context invite the phrase.",
146
+ source_memory_type: "language_pattern", import_policy: "upgrade_on_better",
147
+ },
148
+ });
149
+ }
150
+ for (const txt of lt.tone_that_worked || []) {
151
+ out.push({
152
+ domain: "agent_self", type: "style_adjustment", content: `Works: ${txt}`,
153
+ epistemic_status: "explicit", sensitivity: "low", confidence: 0.95,
154
+ usage_guidance: {
155
+ proactive_use: "only_when_relevant", live_retrieval_eligible: false,
156
+ salience: 0.9, stability: "stable",
157
+ future_response_guidance: "Silently shape voice. Not for quoting.",
158
+ source_memory_type: "language_pattern", import_policy: "upgrade_on_better",
159
+ },
160
+ });
161
+ }
162
+ for (const txt of lt.tone_that_did_not_work || []) {
163
+ out.push({
164
+ domain: "agent_self", type: "style_adjustment", content: `Avoid: ${txt}`,
165
+ epistemic_status: "explicit", sensitivity: "low", confidence: 0.95,
166
+ usage_guidance: {
167
+ proactive_use: "only_when_relevant", live_retrieval_eligible: false,
168
+ salience: 0.9, stability: "stable",
169
+ future_response_guidance: "Silently shape voice. Not for quoting.",
170
+ source_memory_type: "language_pattern", import_policy: "upgrade_on_better",
171
+ },
172
+ });
173
+ }
174
+ return out;
175
+ }
176
+
177
+ function projectCandidates(j) {
178
+ return (j.project_continuity || []).map((p) => ({
179
+ domain: "relational", type: "project",
180
+ content: [
181
+ `Project: ${p.project_name}`,
182
+ `Status: ${p.current_status}`,
183
+ `Built/decided: ${p.what_we_built_or_decided}`,
184
+ p.open_questions?.length ? `Open: ${p.open_questions.join(" | ")}` : null,
185
+ p.next_best_step ? `Next: ${p.next_best_step}` : null,
186
+ ].filter(Boolean).join("\n"),
187
+ epistemic_status: "explicit", sensitivity: "low", confidence: 1.0,
188
+ usage_guidance: {
189
+ proactive_use: "only_when_relevant", live_retrieval_eligible: true,
190
+ salience: p.current_status === "in_progress" ? 0.85 : 0.6,
191
+ stability: p.current_status === "in_progress" ? "recurring" : "stable",
192
+ retrieval_keywords: p.retrieval_keywords || [],
193
+ source_memory_type: "project", import_policy: "upgrade_on_better",
194
+ },
195
+ }));
196
+ }
197
+
198
+ function loopCandidates(j) {
199
+ return (j.open_loops || []).map((l) => ({
200
+ domain: "relational", type: "continuation",
201
+ content: `${l.loop} [${l.status}] — ${l.suggested_future_handling}`,
202
+ epistemic_status: "explicit", sensitivity: "low", confidence: 0.9,
203
+ usage_guidance: {
204
+ proactive_use: l.status === "active" ? "only_when_relevant" : "no",
205
+ live_retrieval_eligible: l.status === "active",
206
+ salience: l.status === "active" ? 0.7 : 0.4,
207
+ stability: l.status === "dormant" ? "uncertain" : "recurring",
208
+ future_response_guidance: l.suggested_future_handling || undefined,
209
+ source_memory_type: "open_loop", import_policy: "upgrade_on_better",
210
+ },
211
+ }));
212
+ }
213
+
214
+ function observationCandidates(j) {
215
+ return (j.unspoken_observations || [])
216
+ .filter((o) => o && o.observation)
217
+ .map((o) => ({
218
+ domain: "relational", type: "interpretive_frame",
219
+ content: [
220
+ o.moment ? `${o.moment}: ${o.observation}` : o.observation,
221
+ o.why_it_matters ? `Why it matters: ${o.why_it_matters}` : null,
222
+ ].filter(Boolean).join(" "),
223
+ evidence: o.basis,
224
+ epistemic_status: "inferred", sensitivity: "medium",
225
+ confidence: typeof o.confidence === "number" ? clamp(o.confidence, 0, 1) : 0.85,
226
+ usage_guidance: {
227
+ proactive_use: "only_when_relevant", live_retrieval_eligible: true,
228
+ salience: 0.85, stability: "stable",
229
+ future_response_guidance: o.why_it_matters || undefined,
230
+ source_memory_type: "emotional_pattern", import_policy: "upgrade_on_better",
231
+ },
232
+ }));
233
+ }
234
+
235
+ function resumeCandidates(j) {
236
+ const out = [];
237
+ const feel = j.narrative_capsule?.what_future_you_should_feel_when_recalled;
238
+ const resume = j.end_state?.how_to_resume;
239
+ for (const [content, salience] of [[feel, 1.0], [resume, 0.95]]) {
240
+ if (!content) continue;
241
+ out.push({
242
+ domain: "agent_self", type: "resume_guidance", content,
243
+ epistemic_status: "explicit", sensitivity: "medium", confidence: 1.0,
244
+ usage_guidance: {
245
+ proactive_use: "yes", live_retrieval_eligible: true,
246
+ salience, stability: "stable",
247
+ future_response_guidance: "Read at session start to calibrate tone. Do not quote.",
248
+ source_memory_type: "agent_self", import_policy: "upgrade_on_better",
249
+ },
250
+ });
251
+ }
252
+ return out;
253
+ }
254
+
255
+ export function buildCandidates(data) {
256
+ return [
257
+ ...recordCandidates(data),
258
+ ...boundaryCandidates(data),
259
+ ...toneCandidates(data),
260
+ ...projectCandidates(data),
261
+ ...loopCandidates(data),
262
+ ...observationCandidates(data),
263
+ ...resumeCandidates(data),
264
+ ];
265
+ }
266
+
267
+ // ─── Session row derivation ──────────────────────────────────────────
268
+
269
+ const KP_BY_MEMORY_TYPE = {
270
+ ritual: "ritual", language_pattern: "language_moment",
271
+ boundary: "user_flagged", emotional_pattern: "emotional_note",
272
+ open_loop: "continuation", project: "decision",
273
+ technical_decision: "decision", aesthetic: "preference",
274
+ agent_self: "emotional_note", user_self: "preference",
275
+ peak_moment: "peak_moment", episodic: "emotional_note",
276
+ relational: "ritual", preference: "preference",
277
+ };
278
+
279
+ export function deriveKeyPoints(data, { max = 18 } = {}) {
280
+ const records = (data.memory_records || [])
281
+ .filter((r) => (r.salience ?? 0) >= 0.7)
282
+ .sort((a, b) => (b.salience ?? 0) - (a.salience ?? 0))
283
+ .slice(0, max);
284
+ return records.map((r) => ({
285
+ type: KP_BY_MEMORY_TYPE[r.memory_type] || "emotional_note",
286
+ content: safeSlice(`${r.title}: ${r.content}`, 340 + (r.title?.length || 0)),
287
+ valence: r.valence ?? 0.5,
288
+ weight: r.salience ?? 0.7,
289
+ }));
290
+ }
291
+
292
+ // ─── Main entry ──────────────────────────────────────────────────────
293
+
294
+ /**
295
+ * @param {object} deps { supabase, embed, memoryStore }
296
+ * @param {object} args
297
+ * @param {object} args.data parsed migration-export JSON
298
+ * @param {number} [args.sessionId] update an existing session row
299
+ * @param {string} [args.source] surface tag for a new row (default 'import')
300
+ * @param {boolean} [args.dryRun]
301
+ */
302
+ export async function importWindow({ supabase, embed, memoryStore }, args) {
303
+ const { data, sessionId: givenId, source = "import", dryRun = false } = args;
304
+ if (!data || typeof data !== "object") throw new Error("importWindow: data required");
305
+
306
+ const sessionFields = {
307
+ headline: safeSlice(data.window_identity?.headline || "", 500) || null,
308
+ detailed_summary: data.narrative_capsule?.detailed_summary || null,
309
+ diary_entry: data.narrative_capsule?.diary_entry_from_you || null,
310
+ thinking_highlights: [],
311
+ key_points: deriveKeyPoints(data),
312
+ end_type: data.end_state?.end_type || "natural",
313
+ };
314
+
315
+ // Resolve or create the session row.
316
+ let sessionId = givenId ?? null;
317
+ if (!dryRun) {
318
+ if (sessionId) {
319
+ const { error } = await supabase
320
+ .from("sessions").update(sessionFields).eq("id", sessionId);
321
+ if (error) throw new Error(`session update: ${error.message}`);
322
+ } else {
323
+ const { data: row, error } = await supabase
324
+ .from("sessions")
325
+ .insert({ ...sessionFields, source, ended_at: new Date().toISOString() })
326
+ .select("id")
327
+ .single();
328
+ if (error) throw new Error(`session insert: ${error.message}`);
329
+ sessionId = row.id;
330
+ }
331
+
332
+ if (sessionFields.detailed_summary) {
333
+ const [vec] = await embed([sessionFields.detailed_summary]);
334
+ if (vec) {
335
+ await supabase.from("sessions")
336
+ .update({ summary_embedding: vec }).eq("id", sessionId);
337
+ }
338
+ }
339
+ }
340
+
341
+ const candidates = buildCandidates(data);
342
+ let memories = { inserted: 0, reinforced: 0, upgraded: 0, skipped: 0, errors: [] };
343
+ if (!dryRun && candidates.length > 0) {
344
+ memories = await memoryStore.upsertMany(candidates, {
345
+ sourceSessionId: sessionId,
346
+ sourceSurface: source,
347
+ });
348
+ }
349
+
350
+ return { sessionId, candidates: candidates.length, memories, dryRun };
351
+ }