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,108 @@
1
+ /**
2
+ * executor.js — pluggable LLM backend for classification.
3
+ *
4
+ * Everything downstream sees one interface:
5
+ * executor.complete({ system, user, maxTokens }) => Promise<string>
6
+ *
7
+ * Two backends ship; bring your own by matching the shape. (A custom
8
+ * executor is also where cost tricks live if you have them — e.g. routing
9
+ * through infrastructure you already pay for. Sostenuto doesn't care how
10
+ * the text comes back.)
11
+ *
12
+ * Model choice: classification is a structured-extraction task — a fast,
13
+ * cheap model is the right default. Reserve your strongest model for the
14
+ * conversation itself.
15
+ */
16
+
17
+ const DEFAULT_MAX_TOKENS = 8000;
18
+
19
+ /** Anthropic Messages API backend. */
20
+ export function createAnthropicExecutor({
21
+ apiKey,
22
+ model = "claude-haiku-4-5-20251001",
23
+ baseUrl = "https://api.anthropic.com",
24
+ } = {}) {
25
+ if (!apiKey) throw new Error("createAnthropicExecutor: apiKey required");
26
+
27
+ async function complete({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
28
+ const res = await fetch(`${baseUrl}/v1/messages`, {
29
+ method: "POST",
30
+ headers: {
31
+ "Content-Type": "application/json",
32
+ "x-api-key": apiKey,
33
+ "anthropic-version": "2023-06-01",
34
+ },
35
+ body: JSON.stringify({
36
+ model,
37
+ max_tokens: maxTokens,
38
+ system,
39
+ messages: [{ role: "user", content: user }],
40
+ }),
41
+ });
42
+ if (!res.ok) {
43
+ throw new Error(`Anthropic ${res.status}: ${(await res.text()).slice(0, 300)}`);
44
+ }
45
+ const json = await res.json();
46
+ return (json.content || [])
47
+ .filter((b) => b.type === "text")
48
+ .map((b) => b.text)
49
+ .join("");
50
+ }
51
+
52
+ return { complete, model, provider: "anthropic" };
53
+ }
54
+
55
+ /**
56
+ * OpenAI-compatible chat-completions backend.
57
+ * Works with OpenAI, Gemini (OpenAI-compat endpoint), DeepSeek, Ollama,
58
+ * LM Studio, vLLM — anything speaking /v1/chat/completions.
59
+ */
60
+ export function createOpenAICompatibleExecutor({ apiKey, model, baseUrl } = {}) {
61
+ if (!baseUrl) throw new Error("createOpenAICompatibleExecutor: baseUrl required");
62
+ if (!model) throw new Error("createOpenAICompatibleExecutor: model required");
63
+
64
+ async function complete({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
65
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/chat/completions`, {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
70
+ },
71
+ body: JSON.stringify({
72
+ model,
73
+ max_tokens: maxTokens,
74
+ messages: [
75
+ { role: "system", content: system },
76
+ { role: "user", content: user },
77
+ ],
78
+ }),
79
+ });
80
+ if (!res.ok) {
81
+ throw new Error(`LLM ${res.status}: ${(await res.text()).slice(0, 300)}`);
82
+ }
83
+ const json = await res.json();
84
+ return json.choices?.[0]?.message?.content ?? "";
85
+ }
86
+
87
+ return { complete, model, provider: "openai-compatible" };
88
+ }
89
+
90
+ /** Build an executor from environment variables (see .env.example). */
91
+ export function executorFromEnv(env = process.env) {
92
+ if (env.CLASSIFY_BASE_URL) {
93
+ return createOpenAICompatibleExecutor({
94
+ baseUrl: env.CLASSIFY_BASE_URL,
95
+ apiKey: env.CLASSIFY_API_KEY,
96
+ model: env.CLASSIFY_MODEL,
97
+ });
98
+ }
99
+ if (env.ANTHROPIC_API_KEY) {
100
+ return createAnthropicExecutor({
101
+ apiKey: env.ANTHROPIC_API_KEY,
102
+ ...(env.CLASSIFY_MODEL ? { model: env.CLASSIFY_MODEL } : {}),
103
+ });
104
+ }
105
+ throw new Error(
106
+ "No classification backend configured: set ANTHROPIC_API_KEY or CLASSIFY_BASE_URL (+ CLASSIFY_MODEL)"
107
+ );
108
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * pipeline.js — classification call + robust parsing + sanitization.
3
+ *
4
+ * The classifier returns JSON; models occasionally wrap it in fences,
5
+ * preface it with prose, or truncate mid-stream at the token limit.
6
+ * parseClassification() survives all three (fence-strip → outer-brace
7
+ * match → truncation salvage that rebalances brackets).
8
+ */
9
+
10
+ import { clamp, safeSlice } from "../memory/guidance.js";
11
+
12
+ export const VALID_KEY_POINT_TYPES = new Set([
13
+ "decision", "open_question", "preference", "user_flagged",
14
+ "continuation", "emotional_note", "ritual", "language_moment", "peak_moment",
15
+ ]);
16
+
17
+ // ─── Parsing ─────────────────────────────────────────────────────────
18
+
19
+ /** Attempt to repair JSON truncated mid-stream at a token limit. */
20
+ function salvageTruncated(text) {
21
+ const lastBrace = text.lastIndexOf("}");
22
+ if (lastBrace === -1) return null;
23
+ let candidate = text.slice(0, lastBrace + 1);
24
+ // Balance any brackets/braces left open before the cut.
25
+ let depthCurly = 0;
26
+ let depthSquare = 0;
27
+ let inString = false;
28
+ let escape = false;
29
+ for (const ch of candidate) {
30
+ if (escape) { escape = false; continue; }
31
+ if (ch === "\\") { escape = true; continue; }
32
+ if (ch === '"') { inString = !inString; continue; }
33
+ if (inString) continue;
34
+ if (ch === "{") depthCurly++;
35
+ else if (ch === "}") depthCurly--;
36
+ else if (ch === "[") depthSquare++;
37
+ else if (ch === "]") depthSquare--;
38
+ }
39
+ if (inString) candidate += '"';
40
+ candidate += "]".repeat(Math.max(0, depthSquare));
41
+ candidate += "}".repeat(Math.max(0, depthCurly));
42
+ try {
43
+ return JSON.parse(candidate);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function parseClassification(rawText) {
50
+ let text = (rawText || "").trim()
51
+ .replace(/^```(?:json)?\s*\n?/i, "")
52
+ .replace(/\n?```\s*$/, "");
53
+ const match = text.match(/\{[\s\S]*\}/);
54
+ if (match) text = match[0];
55
+ try {
56
+ return JSON.parse(text);
57
+ } catch {
58
+ const salvaged = salvageTruncated(text);
59
+ if (salvaged) return salvaged;
60
+ throw new Error(
61
+ `classification JSON unparseable (first 200 chars): ${text.slice(0, 200)}`
62
+ );
63
+ }
64
+ }
65
+
66
+ // ─── Sanitization ────────────────────────────────────────────────────
67
+
68
+ export function sanitizeKeyPoints(raw) {
69
+ if (!Array.isArray(raw)) return [];
70
+ return raw
71
+ .filter(
72
+ (kp) =>
73
+ kp && typeof kp === "object" &&
74
+ typeof kp.content === "string" &&
75
+ VALID_KEY_POINT_TYPES.has(kp.type)
76
+ )
77
+ .map((kp) => {
78
+ const point = { type: kp.type, content: safeSlice(kp.content, 500) };
79
+ if (typeof kp.valence === "number" && !isNaN(kp.valence)) {
80
+ point.valence = clamp(kp.valence, -1, 1);
81
+ }
82
+ if (typeof kp.weight === "number" && !isNaN(kp.weight)) {
83
+ point.weight = clamp(kp.weight, 0, 1);
84
+ }
85
+ return point;
86
+ });
87
+ }
88
+
89
+ export function sanitizeThinkingHighlights(raw) {
90
+ if (!Array.isArray(raw)) return [];
91
+ return raw
92
+ .filter(
93
+ (h) =>
94
+ h && typeof h === "object" &&
95
+ typeof h.moment === "string" &&
96
+ typeof h.thought === "string"
97
+ )
98
+ .map((h) => ({
99
+ moment: safeSlice(h.moment, 200),
100
+ thought: safeSlice(h.thought, 600),
101
+ }))
102
+ .slice(0, 6);
103
+ }
104
+
105
+ /** Normalize a parsed classification into a stable result shape. */
106
+ export function sanitizeClassification(parsed, { hintEndType } = {}) {
107
+ return {
108
+ headline: parsed.headline || parsed.summary || "",
109
+ detailed_summary: parsed.detailed_summary || "",
110
+ diary_entry: parsed.diary_entry || "",
111
+ thinking_highlights: sanitizeThinkingHighlights(parsed.thinking_highlights),
112
+ key_points: sanitizeKeyPoints(parsed.key_points),
113
+ end_type: parsed.end_type || hintEndType || "unknown",
114
+ mood_delta: clamp(parsed.mood_delta ?? 0, -0.5, 0.5),
115
+ connection_delta: clamp(parsed.connection_delta ?? 0, -0.5, 0.5),
116
+ attunement_delta: clamp(parsed.attunement_delta ?? 0, 0, 1),
117
+ candidate_memories: Array.isArray(parsed.candidate_memories)
118
+ ? parsed.candidate_memories
119
+ : [],
120
+ };
121
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * templates.js — load prompt templates with {{variable}} substitution.
3
+ *
4
+ * The classification prompts are FILES YOU EDIT, not strings in our code.
5
+ * Sostenuto's prompts define structure (output schema, calibration rules);
6
+ * your edits define voice (who the companion is, what matters in your
7
+ * relationship). See templates/README in the repo root.
8
+ */
9
+
10
+ import { readFileSync } from "fs";
11
+
12
+ /**
13
+ * Load a template file and substitute {{vars}}.
14
+ * Unknown {{placeholders}} are left intact (so docs can show them).
15
+ */
16
+ export function loadTemplate(path, vars = {}) {
17
+ let text = readFileSync(path, "utf-8");
18
+ for (const [key, value] of Object.entries(vars)) {
19
+ text = text.replaceAll(`{{${key}}}`, String(value ?? ""));
20
+ }
21
+ return text;
22
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * transcript.js — turn formatting for the classifier.
3
+ *
4
+ * Input is surface-agnostic: an array of turns
5
+ * { role: 'user'|'assistant', content: string, thinking?: string, timestamp?: string }
6
+ * Surface adapters (a chat app, a CLI hook, an importer) produce turns
7
+ * however they like; this module only renders them.
8
+ *
9
+ * Two details matter and are deliberate:
10
+ *
11
+ * 1. [thinking] blocks. When the model's reasoning is available, it is
12
+ * included per assistant turn. Reasoning often contains perception that
13
+ * never survived into the rendered reply — the classifier mines it for
14
+ * diary entries and thinking-highlights.
15
+ *
16
+ * 2. Phase markers. Long transcripts get explicit EARLY/MIDDLE/LATE
17
+ * markers. Without them, classifier LLMs reliably collapse a session's
18
+ * arc into its final emotional peak and lose the middle — which is
19
+ * usually where the texture lives.
20
+ */
21
+
22
+ const PHASE_MIN_MESSAGES = 9;
23
+
24
+ export function formatTurn(t) {
25
+ const lines = [`${t.role}: ${t.content}`];
26
+ if (t.role === "assistant" && t.thinking && t.thinking.trim()) {
27
+ lines.push(`[thinking]\n${t.thinking}\n[/thinking]`);
28
+ }
29
+ return lines.join("\n\n");
30
+ }
31
+
32
+ /**
33
+ * Render a full transcript, phase-segmented when long enough.
34
+ */
35
+ export function buildTranscript(turns) {
36
+ if (!turns || turns.length === 0) return "";
37
+ if (turns.length >= PHASE_MIN_MESSAGES) {
38
+ const third = Math.floor(turns.length / 3);
39
+ const early = turns.slice(0, third);
40
+ const middle = turns.slice(third, turns.length - third);
41
+ const late = turns.slice(turns.length - third);
42
+ return [
43
+ "=== EARLY PHASE ===",
44
+ early.map(formatTurn).join("\n\n"),
45
+ "=== MIDDLE PHASE ===",
46
+ middle.map(formatTurn).join("\n\n"),
47
+ "=== LATE PHASE ===",
48
+ late.map(formatTurn).join("\n\n"),
49
+ ].join("\n\n");
50
+ }
51
+ return turns.map(formatTurn).join("\n\n");
52
+ }
53
+
54
+ /** Render only turns after a watermark (incremental classification). */
55
+ export function buildNewTurnsTranscript(turns, fromIndex) {
56
+ return (turns || []).slice(fromIndex).map(formatTurn).join("\n\n");
57
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * guidance.js — usage-policy inference and input sanitization.
3
+ *
4
+ * Every memory object carries a `usage_guidance` policy (machine-read,
5
+ * never dumped into prompts). When the classifier doesn't supply policy
6
+ * fields directly, these deterministic rules infer sensible defaults
7
+ * from type + sensitivity + confidence — no extra LLM call.
8
+ *
9
+ * Design notes baked into the rules:
10
+ * - `proactive_use` controls INITIATIVE, not access. 'no' items remain
11
+ * retrievable when the user explicitly anchors them (high-similarity
12
+ * reference); they're just never volunteered.
13
+ * - Sensitivity does NOT gate retrieval. High-sensitivity memories are
14
+ * part of the relationship and must stay findable when referenced.
15
+ * The gate for "don't auto-surface" is proactive_use, set by policy
16
+ * or curation — not a blanket sensitivity rule.
17
+ */
18
+
19
+ // ─── Vocabularies (must match db/schema.sql CHECK constraints) ───────
20
+
21
+ export const VALID_DOMAINS = new Set([
22
+ "user_self", "agent_self", "relational", "evidence",
23
+ ]);
24
+
25
+ export const VALID_TYPES = new Set([
26
+ "fact", "preference", "trajectory", "somatic_affective",
27
+ "interpretive_frame", "project", "boundary", "commitment",
28
+ "ritual", "shared_concept", "recurring_subject",
29
+ "contradiction", "style_adjustment", "voice_note",
30
+ "constraint", "context_note", "brief", "resume_guidance",
31
+ "continuation", "other",
32
+ ]);
33
+
34
+ export const VALID_EPISTEMIC = new Set([
35
+ "explicit", "inferred", "co_created", "assistant_reflection", "system_generated",
36
+ ]);
37
+
38
+ export const VALID_TIME_SCOPE = new Set([
39
+ "momentary", "session", "active_project", "ongoing", "historical", "deprecated",
40
+ ]);
41
+
42
+ export const VALID_SENSITIVITY = new Set(["low", "medium", "high"]);
43
+
44
+ const SENSITIVITY_RANK = { low: 0, medium: 1, high: 2 };
45
+
46
+ /** Higher-ranked sensitivity wins (used when merging on reinforce). */
47
+ export function maxSensitivity(a, b) {
48
+ return (SENSITIVITY_RANK[a] ?? 0) >= (SENSITIVITY_RANK[b] ?? 0) ? a : b;
49
+ }
50
+
51
+ export function clamp(val, min, max) {
52
+ return Math.max(min, Math.min(max, val));
53
+ }
54
+
55
+ /**
56
+ * Truncate without leaving orphan UTF-16 surrogate halves. Raw .slice()
57
+ * can split an emoji/astral char in two, producing invalid JSON that
58
+ * Postgres JSONB rejects.
59
+ */
60
+ export function safeSlice(s, n) {
61
+ if (!s || s.length <= n) return s || "";
62
+ let out = s.slice(0, n);
63
+ const last = out.charCodeAt(out.length - 1);
64
+ if (last >= 0xd800 && last <= 0xdbff) out = out.slice(0, -1);
65
+ return out;
66
+ }
67
+
68
+ // ─── Domain/type sanitization ────────────────────────────────────────
69
+ // Classifier LLMs occasionally emit values outside the schema vocabulary
70
+ // (e.g. domain:"project"). Map common drift to valid values instead of
71
+ // failing the insert.
72
+
73
+ export function sanitizeDomainType(rawDomain, rawType) {
74
+ let domain = rawDomain;
75
+ let type = rawType;
76
+ let sanitized = false;
77
+
78
+ if (!VALID_DOMAINS.has(domain)) {
79
+ sanitized = true;
80
+ const d = String(domain || "").toLowerCase();
81
+ if (["project", "infrastructure", "technical", "system", "scheduled", "upcoming", "event", "context", "background"].includes(d)) {
82
+ domain = "relational";
83
+ if (!type || !VALID_TYPES.has(type)) {
84
+ if (["project", "infrastructure", "technical", "system"].includes(d)) type = "project";
85
+ else if (["scheduled", "upcoming", "event"].includes(d)) type = "continuation";
86
+ else type = "context_note";
87
+ }
88
+ } else if (d === "user") domain = "user_self";
89
+ else if (["agent", "assistant", "ai", "companion"].includes(d)) domain = "agent_self";
90
+ else if (["quote", "transcript"].includes(d)) domain = "evidence";
91
+ else domain = "relational"; // safest default
92
+ }
93
+
94
+ if (!VALID_TYPES.has(type)) {
95
+ sanitized = true;
96
+ const t = String(type || "").toLowerCase();
97
+ if (["dynamic", "pattern", "interaction"].includes(t)) type = "shared_concept";
98
+ else if (["observation", "linguistic", "note"].includes(t)) type = "voice_note";
99
+ else if (["scheduled", "upcoming", "event", "thread", "open_loop"].includes(t)) type = "continuation";
100
+ else if (["principle", "rule", "guideline"].includes(t)) type = "constraint";
101
+ else type = "other";
102
+ }
103
+
104
+ return { domain, type, sanitized };
105
+ }
106
+
107
+ // ─── Arousal inference ───────────────────────────────────────────────
108
+ // Russell-circumplex intensity, orthogonal to valence: 0 = calm/stable,
109
+ // 1 = acute. Used to modulate decay (high-arousal memories fade slower)
110
+ // and surfacing weight. Prefer a classifier-supplied value; this formula
111
+ // is the fallback:
112
+ //
113
+ // arousal = 0.40·typePrior + 0.40·|valence| + 0.20·salience
114
+
115
+ const AROUSAL_TYPE_PRIOR = {
116
+ // import-taxonomy keys (from migration exports)
117
+ boundary: 0.6, constraint: 0.7,
118
+ emotional_note: 0.8, emotional_pattern: 0.75,
119
+ peak_moment: 0.9, relational: 0.7,
120
+ ritual: 0.45, language_pattern: 0.4,
121
+ project: 0.3, technical_decision: 0.3,
122
+ user_self: 0.3, preference: 0.25,
123
+ aesthetic: 0.35, open_loop: 0.5, episodic: 0.55,
124
+ // schema-type keys
125
+ somatic_affective: 0.7, commitment: 0.55, shared_concept: 0.55,
126
+ trajectory: 0.4, interpretive_frame: 0.55, recurring_subject: 0.5,
127
+ contradiction: 0.55, style_adjustment: 0.45, voice_note: 0.4,
128
+ context_note: 0.3, brief: 0.4, resume_guidance: 0.55,
129
+ continuation: 0.4, fact: 0.2, other: 0.3,
130
+ };
131
+
132
+ function arousalTypePrior({ type, source_memory_type }) {
133
+ // Non-taxonomy markers fall through to the schema type.
134
+ if (
135
+ source_memory_type &&
136
+ source_memory_type !== "backfill" &&
137
+ source_memory_type !== "manual" &&
138
+ AROUSAL_TYPE_PRIOR[source_memory_type] !== undefined
139
+ ) {
140
+ return AROUSAL_TYPE_PRIOR[source_memory_type];
141
+ }
142
+ return AROUSAL_TYPE_PRIOR[type] ?? 0.3;
143
+ }
144
+
145
+ export function inferArousal({ type, source_memory_type, valence, salience }) {
146
+ const tp = arousalTypePrior({ type, source_memory_type });
147
+ const vi = Math.abs(typeof valence === "number" ? valence : 0);
148
+ const sal = typeof salience === "number" ? salience : 0.7;
149
+ return Number(clamp(0.4 * tp + 0.4 * vi + 0.2 * sal, 0, 1).toFixed(3));
150
+ }
151
+
152
+ // ─── Usage-guidance inference ────────────────────────────────────────
153
+
154
+ /**
155
+ * Infer a full usage_guidance object for a new memory.
156
+ *
157
+ * @param {object} m
158
+ * @param {string} m.type schema type (already sanitized)
159
+ * @param {string} m.sensitivity low | medium | high
160
+ * @param {number} [m.confidence] 0..1
161
+ * @param {string} [m.content] used for dormancy detection on continuations
162
+ * @param {number} [m.valence] -1..1, classifier-supplied
163
+ * @param {number} [m.llm_arousal] 0..1, classifier-supplied (preferred over formula)
164
+ * @param {string} [m.source_memory_type] original taxonomy tag from an import
165
+ */
166
+ export function inferUsageGuidance({
167
+ type, sensitivity, confidence, content, valence, llm_arousal, source_memory_type,
168
+ }) {
169
+ const ug = {
170
+ source_memory_type: source_memory_type || type,
171
+ import_policy: "upgrade_on_better",
172
+ stability: "stable",
173
+ salience: clamp(confidence ?? 0.7, 0.5, 1.0),
174
+ };
175
+
176
+ switch (type) {
177
+ case "resume_guidance":
178
+ // How a fresh session should arrive — always-on orientation.
179
+ ug.proactive_use = "yes";
180
+ ug.live_retrieval_eligible = true;
181
+ ug.salience = 1.0;
182
+ ug.future_response_guidance = "Read at session start to calibrate tone. Do not quote.";
183
+ break;
184
+ case "boundary":
185
+ case "constraint":
186
+ ug.proactive_use = "only_when_relevant";
187
+ ug.live_retrieval_eligible = false; // behavior guidance, not retrieval content
188
+ ug.salience = 0.95;
189
+ ug.future_response_guidance = "Silently shape behavior. Do not quote back.";
190
+ break;
191
+ case "context_note":
192
+ ug.proactive_use = "no"; // background context; surfaces only on explicit anchor
193
+ ug.live_retrieval_eligible = false;
194
+ ug.salience = 0.85;
195
+ ug.future_response_guidance = "Background context. Not for quoting.";
196
+ break;
197
+ case "style_adjustment":
198
+ ug.proactive_use = "only_when_relevant";
199
+ ug.live_retrieval_eligible = false;
200
+ ug.salience = 0.9;
201
+ ug.future_response_guidance = "Silently shape voice. Not for quoting.";
202
+ break;
203
+ case "continuation":
204
+ ug.proactive_use = "only_when_relevant";
205
+ ug.live_retrieval_eligible = !/\[dormant\]/i.test(content || "");
206
+ ug.salience = ug.live_retrieval_eligible ? 0.75 : 0.5;
207
+ break;
208
+ default:
209
+ ug.proactive_use = "only_when_relevant";
210
+ // Default true regardless of sensitivity — see module header.
211
+ ug.live_retrieval_eligible = true;
212
+ break;
213
+ }
214
+
215
+ if (typeof valence === "number") ug.valence = clamp(valence, -1, 1);
216
+ if (typeof llm_arousal === "number" && llm_arousal >= 0 && llm_arousal <= 1) {
217
+ ug.arousal = Number(llm_arousal.toFixed(3));
218
+ } else {
219
+ ug.arousal = inferArousal({
220
+ type, source_memory_type, valence: ug.valence, salience: ug.salience,
221
+ });
222
+ }
223
+
224
+ return ug;
225
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * query.js — the curated read paths over memory_objects.
3
+ *
4
+ * Two distinct always-on blocks feed prompt assembly (semantic retrieval
5
+ * is separate — see src/retrieval/):
6
+ *
7
+ * 1. PROACTIVE memories (`proactive_use = 'yes'`) — identity-level
8
+ * orientation the companion carries into every session. Small,
9
+ * curated set.
10
+ *
11
+ * 2. BEHAVIOR GUIDANCE (Tier 2) — boundaries, constraints, and style
12
+ * rules with a curated `should_do` instruction. These silently shape
13
+ * behavior and are never quoted back. Only items that EARNED an
14
+ * instruction appear here; most memories are content-only (Tier 1)
15
+ * and never enter this block — which is how the assembled prompt
16
+ * stays lean instead of becoming a wall of caution.
17
+ */
18
+
19
+ const STATUS_RANK = { reinforced: 0, active: 1, confirmed: 2 };
20
+ const ACTIVE_STATUSES = ["active", "confirmed", "reinforced"];
21
+
22
+ /**
23
+ * Always-on identity/orientation memories.
24
+ * Ranked: status (reinforced first) → confidence.
25
+ */
26
+ export async function getProactiveMemories(supabase, { limit = 20 } = {}) {
27
+ const { data, error } = await supabase
28
+ .from("memory_objects")
29
+ .select("id, domain, type, content, status, confidence, sensitivity, should_do, usage_guidance")
30
+ .in("status", ACTIVE_STATUSES)
31
+ .eq("usage_guidance->>proactive_use", "yes")
32
+ .order("confidence", { ascending: false })
33
+ .limit(limit * 2);
34
+ if (error) throw new Error(`getProactiveMemories: ${error.message}`);
35
+
36
+ const sorted = (data || []).sort((a, b) => {
37
+ const ra = STATUS_RANK[a.status] ?? 9;
38
+ const rb = STATUS_RANK[b.status] ?? 9;
39
+ if (ra !== rb) return ra - rb;
40
+ return (b.confidence ?? 0) - (a.confidence ?? 0);
41
+ });
42
+ return sorted.slice(0, limit);
43
+ }
44
+
45
+ /**
46
+ * Tier 2 behavior guidance.
47
+ *
48
+ * Filter: instructional types, high confidence, `should_do` populated.
49
+ * Excludes proactive_use='yes' (those live in the proactive block —
50
+ * including them here would double-inject) and 'no' (explicit-anchor
51
+ * only). Ranked: salience → evidence_refs count (a rule reinforced
52
+ * across many sessions outranks a one-off correction) → status.
53
+ *
54
+ * Capped small by default (8): lean-not-cautious.
55
+ */
56
+ export async function getBehaviorGuidance(
57
+ supabase,
58
+ { limit = 8, minConfidence = 0.85 } = {}
59
+ ) {
60
+ const { data, error } = await supabase
61
+ .from("memory_objects")
62
+ .select("id, domain, type, content, status, confidence, sensitivity, should_do, should_not_do, usage_guidance, evidence_refs")
63
+ .in("status", ACTIVE_STATUSES)
64
+ .in("type", ["boundary", "constraint", "style_adjustment", "context_note"])
65
+ .gte("confidence", minConfidence)
66
+ .not("should_do", "is", null)
67
+ .order("confidence", { ascending: false })
68
+ .limit(limit * 3);
69
+ if (error) throw new Error(`getBehaviorGuidance: ${error.message}`);
70
+
71
+ const filtered = (data || []).filter((m) => {
72
+ const pu = m.usage_guidance?.proactive_use;
73
+ return pu !== "yes" && pu !== "no";
74
+ });
75
+
76
+ filtered.sort((a, b) => {
77
+ const sa = a.usage_guidance?.salience ?? 0;
78
+ const sb = b.usage_guidance?.salience ?? 0;
79
+ if (sa !== sb) return sb - sa;
80
+ const ea = Array.isArray(a.evidence_refs) ? a.evidence_refs.length : 0;
81
+ const eb = Array.isArray(b.evidence_refs) ? b.evidence_refs.length : 0;
82
+ if (ea !== eb) return eb - ea;
83
+ return (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
84
+ });
85
+
86
+ return filtered.slice(0, limit);
87
+ }
88
+
89
+ /** Soft-delete: mark a memory deprecated with a reason in version_history. */
90
+ export async function deprecateMemory(supabase, id, reason) {
91
+ const { data: existing, error: fetchErr } = await supabase
92
+ .from("memory_objects")
93
+ .select("status, version_history, usage_guidance")
94
+ .eq("id", id)
95
+ .single();
96
+ if (fetchErr) throw new Error(`deprecateMemory fetch #${id}: ${fetchErr.message}`);
97
+
98
+ const { error } = await supabase
99
+ .from("memory_objects")
100
+ .update({
101
+ status: "deprecated",
102
+ usage_guidance: { ...(existing.usage_guidance || {}), proactive_use: "no" },
103
+ version_history: [
104
+ ...(existing.version_history || []),
105
+ { prev_status: existing.status, deprecated_at: new Date().toISOString(), reason: reason || null },
106
+ ],
107
+ updated_at: new Date().toISOString(),
108
+ })
109
+ .eq("id", id);
110
+ if (error) throw new Error(`deprecateMemory #${id}: ${error.message}`);
111
+ }