sostenuto 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/db/schema.sql +302 -0
- package/docs/deployment-patterns.md +128 -0
- package/docs/memory-model.md +105 -0
- package/docs/safety.md +112 -0
- package/mcp/server.js +174 -0
- package/package.json +58 -0
- package/src/classify/close.js +266 -0
- package/src/classify/executor.js +108 -0
- package/src/classify/pipeline.js +121 -0
- package/src/classify/templates.js +22 -0
- package/src/classify/transcript.js +57 -0
- package/src/memory/guidance.js +225 -0
- package/src/memory/query.js +111 -0
- package/src/memory/store.js +205 -0
- package/src/migrate/import.js +351 -0
- package/src/retrieval/assembly.js +287 -0
- package/src/retrieval/embeddings.js +84 -0
- package/src/retrieval/search.js +173 -0
- package/templates/classify-full.md +71 -0
- package/templates/classify-incremental.md +28 -0
- package/templates/migration-export.md +163 -0
- package/templates/persona.example.md +43 -0
|
@@ -0,0 +1,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
|
+
}
|