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
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
|
+
}
|