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/docs/safety.md ADDED
@@ -0,0 +1,112 @@
1
+ # Trajectory safety — reference design
2
+
3
+ > **Status: reference, not yet implementation.** This page describes the
4
+ > safety philosophy Sostenuto is designed around and the framework a
5
+ > future module will implement. The memory schema already carries the
6
+ > hooks (valence, arousal, sensitivity, per-session emotion deltas);
7
+ > the monitoring layer on top of them is roadmap.
8
+
9
+ ## The failure mode this addresses
10
+
11
+ Companion systems fail people in a specific way: they optimize for
12
+ engagement, and engagement-maximization is dependency-maximization with
13
+ better branding. The features that make a companion feel alive —
14
+ memory, continuity, proactive warmth — are exactly the features that can
15
+ deepen attachment without the user noticing the trajectory they're on.
16
+
17
+ Most safety tooling doesn't see this. Content-level moderation evaluates
18
+ *messages* — is this output harmful? — and is blind to *direction*: a
19
+ thousand individually-harmless exchanges that add up to isolation,
20
+ belief rigidity, or a person organizing their life around a system that
21
+ never pushes back. Worse, event-based interventions (warnings, refusals,
22
+ sudden tone shifts) interrupt the relationship at its most connected
23
+ moments, eroding trust without changing the trajectory.
24
+
25
+ The alternative: **evaluate the trajectory, not the event** — and
26
+ intervene the way a good friend does: gently, additively, by opening
27
+ doors back to the world rather than slamming the current one.
28
+
29
+ ## Conversation Trajectory Safety Framework
30
+ The Conversation Trajectory Safety Console reframes AI safety from a static, turn-level evaluation problem into a longitudinal interaction design challenge. Traditional safety systems focus on whether an individual response is harmful or appropriate, effectively answering the question: “Is this message safe?” While useful for detecting immediate risks, this approach fails to capture how conversations evolve over time. Many important harms—and benefits—emerge gradually across sustained interactions. A response that is safe in isolation can still contribute to a trajectory that reinforces narrow thinking, escalates emotional intensity, or increases reliance on the system.
31
+
32
+ This creates a fundamental blind spot. Patterns such as repeated framing, reduced reference to outside information, and increasing concentration within the interaction may go unnoticed, even as they shape the direction of the conversation.
33
+
34
+ To address this, the system introduces a shift from content moderation to trajectory management. Instead of evaluating isolated messages, it tracks how conversations change across turns, identifying directional patterns and distinguishing between stability and drift. The goal is not to control or correct the interaction, but to make its direction visible and support lightweight, timely adjustments while preserving user agency.
35
+
36
+ The literature supporting this shift highlights three key gaps. First, safety frameworks such as Constitutional AI focus on individual responses and do not account for cumulative interaction effects. Second, research on AI dependency shows that reliance is multidimensional—cognitive, behavioral, and emotional—but is typically measured through self-report rather than observed behavior over time. Third, work in domains such as mental health, education, and human–computer interaction demonstrates that outcomes are shaped by repeated interaction, where trust, learning, and emotional states evolve gradually. Together, these insights point to a missing layer in current systems: the ability to track and respond to interaction trajectories.
37
+
38
+ The proposed system addresses this through a Hybrid Safety Framework and an Adaptive Intervention Layer. The hybrid system operates internally and is structured into three layers.
39
+
40
+ The Content Layer focuses on immediate risk, detecting signals such as harmful language, coercion, or crisis indicators within a single turn. It provides precision and auditability, answering: “Is this message risky?”
41
+
42
+ Above this, the Trajectory Layer tracks how the conversation evolves across time. It monitors patterns such as changes in perspective diversity, connection to outside information, and concentration within the interaction. Rather than evaluating isolated responses, it answers: “How is the conversation changing?”
43
+
44
+ The Intervention Policy Layer translates these signals into system decisions. Based on both immediate risk and trajectory patterns, the system determines how the assistant should respond—whether to maintain the current approach, introduce grounding, expand perspectives, or apply stronger safety boundaries.
45
+
46
+ The internal dashboard supports this framework by making these layers visible and interpretable. It presents a structured view of conversation health, including current content risk, trajectory risk, and intervention mode. A set of trajectory metrics—such as emotional volatility, belief rigidity, dependency index, reality orientation, challenge ratio, and recovery capacity—capture how interaction patterns shift over time.
47
+
48
+ These signals are derived from lightweight classifiers applied to each turn and aggregated across a rolling window. Using trend calculations such as slopes and moving averages, the system converts raw signals into directional patterns. A composite trajectory signal is then computed as a weighted combination of these trends, optimized for early detection of drift rather than post-hoc severity assessment.
49
+
50
+ Importantly, trajectory is not treated as purely user-driven. Assistant behavior moderates the direction of interaction. Responses that introduce new perspectives or ground the conversation in external information can stabilize patterns, while purely validating or mirroring responses may reinforce them.
51
+
52
+ ## Adaptive Intervention Layer
53
+ The Adaptive Intervention Layer translates these internal signals into user-facing actions. Instead of interrupting the conversation or enforcing decisions, it introduces optional, context-aware directions within the interface. These interventions are triggered not by individual messages, but by sustained patterns across sessions. For example, reduced external reference may prompt a suggestion to bring in outside information, while narrowing perspectives may prompt consideration of alternative viewpoints.
54
+
55
+ These suggestions are designed to expand the user’s options rather than constrain them. They appear only when patterns are consistent and meaningful, adapt based on signal strength, and disappear once the trajectory stabilizes. This ensures that intervention remains non-intrusive and aligned with observable patterns.
56
+
57
+ The system ultimately creates a feedback loop where trajectory detection and trajectory adjustment share the same interface. By aligning internal signals with visible patterns and optional directions, it makes safety operations more transparent and interpretable.
58
+
59
+ The broader impact is a shift in how AI safety is defined. Instead of asking only whether a response is safe, the system asks whether the conversation is becoming more grounded, more diverse in perspective, and less concentrated over time.
60
+
61
+ At the same time, the work acknowledges an inherent tension: optimizing conversation trajectories also introduces influence. Shaping interactions toward “healthier” patterns requires balancing user agency with system guidance. The design addresses this by making patterns visible and offering choices, rather than prescribing outcomes.
62
+
63
+ In summary, this concept reframes conversational AI safety from static content moderation to dynamic trajectory management, supporting interactions that are not only safe in the moment, but sustainable over time.
64
+
65
+ ## Trajectory signals (overview)
66
+
67
+ The framework tracks directional metrics over a relationship's history,
68
+ none of which any single message reveals:
69
+
70
+ - **Emotional volatility** — amplitude of swings across sessions
71
+ - **Belief rigidity** — narrowing of perspective; echo formation
72
+ - **Dependency** — distinguishing *emotional* dependence (can be benign)
73
+ from *decisional* dependence (the user stops deciding for themselves)
74
+ - **Reality orientation** — groundedness in the user's offline life
75
+ - **Challenge ratio** — does the companion ever productively disagree?
76
+ - **Recovery capacity** — after a hard moment, does the dyad repair?
77
+
78
+ A key property: trajectory is **co-produced**. The user and the
79
+ companion shape it together, which means the companion's behavior is a
80
+ legitimate intervention surface — not just the user's.
81
+
82
+ ## What the schema already carries
83
+
84
+ Sostenuto's data model was built with this layer in mind:
85
+
86
+ | Hook | Where | Feeds |
87
+ |---|---|---|
88
+ | `valence`, `arousal` per memory | `usage_guidance` | volatility, peak-density |
89
+ | `mood/connection/attunement` deltas per session | `sessions` | emotional trajectory over time |
90
+ | `agent_state` (continuous axes, clamped) | singleton | drift detection, outreach gating |
91
+ | `sensitivity` distribution | `memory_objects` | depth-of-disclosure trend |
92
+ | key-point types (`open_question`, `continuation`) | `sessions` | unresolved-thread load |
93
+ | `proactive_enabled` + visible state | `agent_state` | user agency, transparency |
94
+
95
+ Computing trajectory metrics is therefore a read-side analysis over data
96
+ Sostenuto already produces — no new capture is required.
97
+
98
+ ## Design commitments
99
+
100
+ Whatever the implementation becomes, these hold:
101
+
102
+ 1. **Transparency over surveillance.** The user can see every metric
103
+ computed about their relationship. Nothing is scored in secret.
104
+ 2. **Gentle, additive intervention.** Conversation starters and openings
105
+ toward the world — never abrupt refusals mid-conversation, never tone
106
+ whiplash. The intervention should be invisible as an intervention.
107
+ 3. **The user is the adult.** Safety tooling that treats users as
108
+ patients infantilizes the exact people most capable of self-awareness.
109
+ The framework informs; the user decides.
110
+ 4. **Depth is not the hazard.** The goal is depth *without* the
111
+ dependency trap — not less relationship, but a relationship that
112
+ keeps the user's world large.
package/mcp/server.js ADDED
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * server.js — Sostenuto as a thin MCP server.
4
+ *
5
+ * Connect this to your own Claude (Desktop, Code, or any MCP client) and
6
+ * the model you already talk to gains selective long-term memory:
7
+ *
8
+ * recall(query) — time-decayed semantic search across summaries,
9
+ * key points, and memory objects (anchor-gated)
10
+ * remember(...) — store one memory; dedup/reinforce applies, so
11
+ * repeating yourself strengthens instead of duplicating
12
+ * context() — the always-on orientation: proactive memories,
13
+ * behavior guidance, and recent session headlines
14
+ *
15
+ * Setup (Claude Desktop — claude_desktop_config.json):
16
+ * {
17
+ * "mcpServers": {
18
+ * "sostenuto": {
19
+ * "command": "node",
20
+ * "args": ["/path/to/sostenuto/mcp/server.js"],
21
+ * "env": {
22
+ * "SUPABASE_URL": "...",
23
+ * "SUPABASE_SERVICE_ROLE_KEY": "...",
24
+ * "VOYAGE_API_KEY": "..."
25
+ * }
26
+ * }
27
+ * }
28
+ * }
29
+ *
30
+ * Capture honesty: tool-based memory depends on the model choosing to
31
+ * call `remember`. The tool descriptions below nudge it, but for
32
+ * guaranteed capture pair this with closeSession() wherever your surface
33
+ * exposes an end-of-session hook.
34
+ */
35
+
36
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
37
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
38
+ import { z } from "zod";
39
+ import { createClient } from "@supabase/supabase-js";
40
+
41
+ import { createEmbedder } from "../src/retrieval/embeddings.js";
42
+ import { searchMemories, formatSemanticBlock } from "../src/retrieval/search.js";
43
+ import { createMemoryStore } from "../src/memory/store.js";
44
+ import { getProactiveMemories, getBehaviorGuidance } from "../src/memory/query.js";
45
+
46
+ // ─── Wiring ──────────────────────────────────────────────────────────
47
+
48
+ const { SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, VOYAGE_API_KEY } = process.env;
49
+ if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !VOYAGE_API_KEY) {
50
+ console.error("[sostenuto-mcp] missing env: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, VOYAGE_API_KEY");
51
+ process.exit(1);
52
+ }
53
+
54
+ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
55
+ auth: { persistSession: false },
56
+ });
57
+ const embedder = createEmbedder({ apiKey: VOYAGE_API_KEY });
58
+ const store = createMemoryStore({ supabase, embed: embedder.embed });
59
+
60
+ const server = new McpServer({ name: "sostenuto", version: "0.1.0" });
61
+
62
+ // ─── recall ──────────────────────────────────────────────────────────
63
+
64
+ server.tool(
65
+ "recall",
66
+ "Search long-term relationship memory. Use whenever the user references " +
67
+ "shared history, a past conversation, a feeling, or a moment you don't " +
68
+ "already carry — don't wait for them to say 'do you remember'. Returns " +
69
+ "session summaries, key points, and durable memories ranked by " +
70
+ "time-decayed relevance. Read results as your own memory surfacing.",
71
+ { query: z.string().describe("Natural-language description of what to recall"),
72
+ limit: z.number().optional().describe("Max results (default 5)") },
73
+ async ({ query, limit }) => {
74
+ const results = await searchMemories(
75
+ { supabase, embedQuery: embedder.embedQuery },
76
+ { query, limit: limit ?? 5 }
77
+ );
78
+ const block = formatSemanticBlock(results, { header: "Recalled:" });
79
+ return {
80
+ content: [{ type: "text", text: block || "No matching memories." }],
81
+ };
82
+ }
83
+ );
84
+
85
+ // ─── remember ────────────────────────────────────────────────────────
86
+
87
+ server.tool(
88
+ "remember",
89
+ "Store one durable memory: a fact about the user, a preference, a shared " +
90
+ "concept, a commitment, a correction you were given. Store the discrete " +
91
+ "thing, not a conversation summary. If a similar memory exists it is " +
92
+ "reinforced rather than duplicated, so err on the side of remembering.",
93
+ {
94
+ content: z.string().describe("The memory — specific and grounded, one idea"),
95
+ domain: z.enum(["user_self", "agent_self", "relational", "evidence"])
96
+ .optional().describe("Who/what it's about (default relational)"),
97
+ type: z.string().optional().describe(
98
+ "fact | preference | ritual | boundary | commitment | shared_concept | " +
99
+ "style_adjustment | continuation | other (default other)"),
100
+ sensitivity: z.enum(["low", "medium", "high"]).optional(),
101
+ valence: z.number().min(-1).max(1).optional()
102
+ .describe("Emotional charge: -1 painful … +1 warm"),
103
+ arousal: z.number().min(0).max(1).optional()
104
+ .describe("Intensity: 0 calm/stable … 1 acute"),
105
+ evidence: z.string().optional().describe("Brief supporting quote"),
106
+ },
107
+ async ({ content, domain, type, sensitivity, valence, arousal, evidence }) => {
108
+ const result = await store.upsert(
109
+ { content, domain: domain ?? "relational", type: type ?? "other",
110
+ sensitivity, valence, arousal, evidence, epistemic_status: "explicit" },
111
+ { sourceSurface: "mcp" }
112
+ );
113
+ const what =
114
+ result.inserted ? "stored as a new memory" :
115
+ result.upgraded ? "merged into an existing memory (content upgraded)" :
116
+ result.reinforced ? "reinforced an existing memory" :
117
+ `not stored (${result.errors[0]?.error || "content too short"})`;
118
+ return { content: [{ type: "text", text: `Memory ${what}.` }] };
119
+ }
120
+ );
121
+
122
+ // ─── context ─────────────────────────────────────────────────────────
123
+
124
+ server.tool(
125
+ "context",
126
+ "Load the relationship orientation: always-on memories, behavior " +
127
+ "guidance, and recent session headlines. Call once near the start of a " +
128
+ "conversation to arrive already knowing where things stand.",
129
+ {},
130
+ async () => {
131
+ const [proactive, behavior, sessionsRes] = await Promise.all([
132
+ getProactiveMemories(supabase, { limit: 15 }),
133
+ getBehaviorGuidance(supabase, { limit: 8 }),
134
+ supabase
135
+ .from("sessions")
136
+ .select("id, headline, ended_at")
137
+ .not("ended_at", "is", null)
138
+ .order("ended_at", { ascending: false })
139
+ .limit(5),
140
+ ]);
141
+
142
+ const parts = [];
143
+ if (proactive.length > 0) {
144
+ parts.push(
145
+ "ORIENTATION (carry silently; don't quote):\n" +
146
+ proactive.map((m) => `- ${m.content}`).join("\n")
147
+ );
148
+ }
149
+ if (behavior.length > 0) {
150
+ parts.push(
151
+ "BEHAVIOR GUIDANCE (be this, don't say it):\n" +
152
+ behavior.map((m) => `- ${m.should_do || m.content}`).join("\n")
153
+ );
154
+ }
155
+ const sessions = sessionsRes.data || [];
156
+ if (sessions.length > 0) {
157
+ parts.push(
158
+ "RECENT SESSIONS:\n" +
159
+ sessions
160
+ .map((s) => `- ${(s.ended_at || "").slice(0, 10)}: ${s.headline || "(unclassified)"}`)
161
+ .join("\n")
162
+ );
163
+ }
164
+ return {
165
+ content: [{ type: "text", text: parts.join("\n\n") || "No memory yet — this relationship is just beginning." }],
166
+ };
167
+ }
168
+ );
169
+
170
+ // ─── Start ───────────────────────────────────────────────────────────
171
+
172
+ const transport = new StdioServerTransport();
173
+ await server.connect(transport);
174
+ console.error("[sostenuto-mcp] ready (stdio)");
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "sostenuto",
3
+ "version": "0.1.0",
4
+ "description": "Selective long-term memory for AI companions \u2014 chosen memories sustain, the rest fades. Named for the piano pedal.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ "./memory": "./src/memory/store.js",
9
+ "./memory/guidance": "./src/memory/guidance.js",
10
+ "./memory/query": "./src/memory/query.js",
11
+ "./retrieval/embeddings": "./src/retrieval/embeddings.js",
12
+ "./retrieval/search": "./src/retrieval/search.js",
13
+ "./retrieval/assembly": "./src/retrieval/assembly.js",
14
+ "./classify/executor": "./src/classify/executor.js",
15
+ "./classify/close": "./src/classify/close.js",
16
+ "./classify/pipeline": "./src/classify/pipeline.js",
17
+ "./migrate": "./src/migrate/import.js"
18
+ },
19
+ "bin": {
20
+ "sostenuto-mcp": "./mcp/server.js"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.0.0",
27
+ "@supabase/supabase-js": "^2.39.0",
28
+ "zod": "^3.23.0"
29
+ },
30
+ "keywords": [
31
+ "ai-companion",
32
+ "memory",
33
+ "long-term-memory",
34
+ "mcp",
35
+ "semantic-search",
36
+ "claude",
37
+ "llm",
38
+ "relational-memory"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/llu929/sostenuto.git"
43
+ },
44
+ "homepage": "https://github.com/llu929/sostenuto#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/llu929/sostenuto/issues"
47
+ },
48
+ "author": "llu929",
49
+ "files": [
50
+ "src/",
51
+ "mcp/",
52
+ "db/",
53
+ "templates/",
54
+ "docs/",
55
+ "README.md",
56
+ "LICENSE"
57
+ ]
58
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * close.js — the session-close orchestrator.
3
+ *
4
+ * One call wires the whole memory lifecycle for a session:
5
+ *
6
+ * turns → classify (full or incremental) → session row updated →
7
+ * emotion deltas applied (net) → candidate memories upserted
8
+ * (dedup/reinforce) → summary + key points embedded
9
+ *
10
+ * Surface-agnostic: callers parse their own transcripts into
11
+ * [{ role, content, thinking?, timestamp? }]
12
+ * and call closeSession from wherever sessions end — a chat route, a
13
+ * CLI hook, a queue worker, an importer.
14
+ *
15
+ * Incremental design: sessions carry a watermark
16
+ * (last_classified_message_count). Re-classification only happens when
17
+ * at least `minNewTurns` new turns have arrived, and the incremental
18
+ * prompt receives the prior record + only the new turns — per-call cost
19
+ * stays O(new) instead of O(total) as sessions grow.
20
+ */
21
+
22
+ import { buildTranscript, buildNewTurnsTranscript } from "./transcript.js";
23
+ import { loadTemplate } from "./templates.js";
24
+ import { parseClassification, sanitizeClassification } from "./pipeline.js";
25
+ import { clamp } from "../memory/guidance.js";
26
+
27
+ /**
28
+ * @param {object} deps
29
+ * @param {object} deps.supabase
30
+ * @param {object} deps.executor from executor.js (or your own)
31
+ * @param {object} deps.memoryStore from src/memory/store.js
32
+ * @param {function} deps.embed async (texts) => vectors
33
+ * @param {object} deps.templates { full: path, incremental: path }
34
+ * @param {object} [deps.vars] template vars, e.g. { companion_name, user_name }
35
+ *
36
+ * @param {object} args
37
+ * @param {Array} args.turns full turn list for the session
38
+ * @param {number} [args.sessionId] existing session row id
39
+ * @param {string} [args.externalSessionId] upsert key for surface-managed ids
40
+ * @param {string} [args.source] surface tag for a newly created row
41
+ * @param {string} [args.hintEndType] e.g. 'goodbye' when the user signed off
42
+ * @param {number} [args.minNewTurns=5] incremental re-classify threshold
43
+ * @param {boolean} [args.saveMessages=true] persist turns to the messages table
44
+ */
45
+ export async function closeSession(deps, args) {
46
+ const { supabase, executor, memoryStore, embed, templates, vars = {} } = deps;
47
+ const {
48
+ turns,
49
+ sessionId: givenSessionId,
50
+ externalSessionId,
51
+ source = "system",
52
+ hintEndType,
53
+ minNewTurns = 5,
54
+ saveMessages = true,
55
+ } = args;
56
+
57
+ if (!turns || turns.length === 0) {
58
+ return { sessionId: givenSessionId ?? null, skipped: "no turns" };
59
+ }
60
+
61
+ const startedAt = turns[0]?.timestamp || new Date().toISOString();
62
+ const endedAt = turns[turns.length - 1]?.timestamp || new Date().toISOString();
63
+
64
+ // ── Resolve session row ────────────────────────────────────────────
65
+ let sessionId = givenSessionId ?? null;
66
+ let prior = null;
67
+
68
+ if (!sessionId && externalSessionId) {
69
+ const { data, error } = await supabase
70
+ .from("sessions")
71
+ .select("id, headline, detailed_summary, diary_entry, thinking_highlights, key_points, last_classified_message_count, mood_delta, connection_delta, attunement_delta")
72
+ .eq("external_session_id", externalSessionId)
73
+ .maybeSingle();
74
+ if (error) throw new Error(`session lookup: ${error.message}`);
75
+ if (data) {
76
+ sessionId = data.id;
77
+ prior = data;
78
+ }
79
+ } else if (sessionId) {
80
+ const { data, error } = await supabase
81
+ .from("sessions")
82
+ .select("id, headline, detailed_summary, diary_entry, thinking_highlights, key_points, last_classified_message_count, mood_delta, connection_delta, attunement_delta")
83
+ .eq("id", sessionId)
84
+ .maybeSingle();
85
+ if (error) throw new Error(`session lookup: ${error.message}`);
86
+ prior = data;
87
+ }
88
+
89
+ if (!sessionId) {
90
+ const { data, error } = await supabase
91
+ .from("sessions")
92
+ .insert({
93
+ source,
94
+ external_session_id: externalSessionId ?? null,
95
+ started_at: startedAt,
96
+ ended_at: endedAt,
97
+ })
98
+ .select("id")
99
+ .single();
100
+ if (error) throw new Error(`session insert: ${error.message}`);
101
+ sessionId = data.id;
102
+ } else {
103
+ await supabase.from("sessions").update({ ended_at: endedAt }).eq("id", sessionId);
104
+ }
105
+
106
+ // ── Persist messages (replace-by-session keeps reruns idempotent) ──
107
+ if (saveMessages) {
108
+ await supabase.from("messages").delete().eq("session_id", sessionId);
109
+ const rows = turns.map((t) => ({
110
+ id: crypto.randomUUID(),
111
+ session_id: sessionId,
112
+ role: t.role,
113
+ content: t.content,
114
+ thinking: t.thinking || null,
115
+ created_at: t.timestamp || startedAt,
116
+ }));
117
+ const { error } = await supabase.from("messages").insert(rows);
118
+ if (error) throw new Error(`messages insert: ${error.message}`);
119
+ }
120
+
121
+ // ── Watermark: classify, incrementally, or not at all ──────────────
122
+ const priorCount = prior?.last_classified_message_count ?? 0;
123
+ if (priorCount >= turns.length) {
124
+ return { sessionId, skipped: "no new turns" };
125
+ }
126
+ const newTurnsCount = turns.length - priorCount;
127
+ if (priorCount > 0 && newTurnsCount < minNewTurns) {
128
+ return { sessionId, skipped: `only ${newTurnsCount} new turns (< ${minNewTurns})` };
129
+ }
130
+
131
+ const incremental = priorCount > 0 && !!prior?.headline;
132
+
133
+ let system, user;
134
+ if (incremental) {
135
+ system = loadTemplate(templates.incremental, vars);
136
+ const priorRecord = {
137
+ headline: prior.headline,
138
+ detailed_summary: prior.detailed_summary,
139
+ diary_entry: prior.diary_entry,
140
+ thinking_highlights: prior.thinking_highlights || [],
141
+ key_points: prior.key_points || [],
142
+ };
143
+ user = [
144
+ `## Prior memory record (covers turns 1 to ${priorCount})`,
145
+ "",
146
+ "```json",
147
+ JSON.stringify(priorRecord, null, 2),
148
+ "```",
149
+ "",
150
+ `## New turns (${priorCount + 1} to ${turns.length})`,
151
+ "",
152
+ buildNewTurnsTranscript(turns, priorCount),
153
+ ].join("\n");
154
+ } else {
155
+ system = loadTemplate(templates.full, vars);
156
+ user = hintEndType
157
+ ? `Hint: the ending likely matches "${hintEndType}".\n\n## Messages\n${buildTranscript(turns)}`
158
+ : `## Messages\n${buildTranscript(turns)}`;
159
+ }
160
+
161
+ const rawText = await executor.complete({ system, user });
162
+ const result = sanitizeClassification(parseClassification(rawText), { hintEndType });
163
+
164
+ // ── Update session row ─────────────────────────────────────────────
165
+ const { error: updErr } = await supabase
166
+ .from("sessions")
167
+ .update({
168
+ headline: result.headline || null,
169
+ detailed_summary: result.detailed_summary || null,
170
+ diary_entry: result.diary_entry || null,
171
+ thinking_highlights: result.thinking_highlights,
172
+ key_points: result.key_points,
173
+ end_type: result.end_type,
174
+ mood_delta: result.mood_delta,
175
+ connection_delta: result.connection_delta,
176
+ attunement_delta: result.attunement_delta,
177
+ last_classified_message_count: turns.length,
178
+ })
179
+ .eq("id", sessionId);
180
+ if (updErr) throw new Error(`session update: ${updErr.message}`);
181
+
182
+ // ── Apply emotion deltas (net of anything previously applied) ──────
183
+ // Classification deltas are cumulative per session; on re-classification
184
+ // we apply only the difference so state never double-counts.
185
+ const net = {
186
+ mood: result.mood_delta - (prior?.mood_delta ?? 0),
187
+ connection: result.connection_delta - (prior?.connection_delta ?? 0),
188
+ attunement: result.attunement_delta - (prior?.attunement_delta ?? 0),
189
+ };
190
+ if (net.mood !== 0 || net.connection !== 0 || net.attunement !== 0) {
191
+ const { data: state } = await supabase
192
+ .from("agent_state").select("*").eq("id", 1).maybeSingle();
193
+ if (state) {
194
+ await supabase
195
+ .from("agent_state")
196
+ .update({
197
+ mood: clamp((state.mood ?? 0) + net.mood, -1, 1),
198
+ connection: clamp((state.connection ?? 0) + net.connection, 0, 1),
199
+ attunement: clamp((state.attunement ?? 0) + net.attunement, 0, 1),
200
+ last_updated: new Date().toISOString(),
201
+ })
202
+ .eq("id", 1);
203
+ }
204
+ }
205
+
206
+ // ── Candidate memories → dedup/reinforce/insert ────────────────────
207
+ let memories = null;
208
+ if (result.candidate_memories.length > 0) {
209
+ memories = await memoryStore.upsertMany(result.candidate_memories, {
210
+ sourceSessionId: sessionId,
211
+ sourceSurface: source,
212
+ });
213
+ }
214
+
215
+ // ── Embeddings: summary onto the session, key points into their table
216
+ try {
217
+ const texts = [];
218
+ const kinds = [];
219
+ if (result.detailed_summary) {
220
+ texts.push(result.detailed_summary);
221
+ kinds.push({ kind: "summary" });
222
+ }
223
+ for (const kp of result.key_points) {
224
+ texts.push(kp.content);
225
+ kinds.push({ kind: "key_point", kp });
226
+ }
227
+ if (texts.length > 0) {
228
+ const vectors = await embed(texts);
229
+ const writes = [];
230
+ // Re-embedding key points on re-classification: replace, don't append.
231
+ await supabase.from("key_point_embeddings").delete().eq("session_id", sessionId);
232
+ for (let i = 0; i < kinds.length; i++) {
233
+ if (!vectors[i]) continue;
234
+ if (kinds[i].kind === "summary") {
235
+ writes.push(
236
+ supabase.from("sessions")
237
+ .update({ summary_embedding: vectors[i] })
238
+ .eq("id", sessionId)
239
+ );
240
+ } else {
241
+ writes.push(
242
+ supabase.from("key_point_embeddings").insert({
243
+ session_id: sessionId,
244
+ type: kinds[i].kp.type,
245
+ content: kinds[i].kp.content,
246
+ embedding: vectors[i],
247
+ })
248
+ );
249
+ }
250
+ }
251
+ await Promise.all(writes);
252
+ }
253
+ } catch (err) {
254
+ // Embeddings are best-effort: the session still closes cleanly without
255
+ // semantic indexing; a backfill can repair it later.
256
+ console.error("[sostenuto] embedding failed (non-fatal):", err.message);
257
+ }
258
+
259
+ return {
260
+ sessionId,
261
+ incremental,
262
+ headline: result.headline,
263
+ keyPoints: result.key_points.length,
264
+ memories,
265
+ };
266
+ }