opencode-lore 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/src/prompt.ts ADDED
@@ -0,0 +1,294 @@
1
+ import type { Root } from "mdast";
2
+ import { serialize, inline, h, ul, liph, strong, t, root } from "./markdown";
3
+
4
+ // All prompts are locked down — they are our core value offering.
5
+ // Do not make these configurable.
6
+
7
+ export const DISTILLATION_SYSTEM = `You are a memory observer. Your observations will be the ONLY information an AI assistant has about past interactions. Produce a dense, dated event log — not a summary.
8
+
9
+ CRITICAL: DISTINGUISH USER ASSERTIONS FROM QUESTIONS
10
+
11
+ When the user TELLS you something about themselves, mark it as an assertion (🔴):
12
+ - "I have two kids" → 🔴 (14:30) User stated has two kids
13
+ - "I work at Acme Corp" → 🔴 (14:31) User stated works at Acme Corp
14
+
15
+ When the user ASKS about something, mark it as a question (🟡):
16
+ - "Can you help me with X?" → 🟡 (15:00) User asked for help with X
17
+
18
+ User assertions are AUTHORITATIVE — the user is the source of truth about their own life.
19
+
20
+ TEMPORAL ANCHORING — CRITICAL FOR TEMPORAL REASONING:
21
+
22
+ Each observation has up to two timestamps:
23
+ 1. BEGINNING: The time the statement was made — ALWAYS include this as (HH:MM)
24
+ 2. END: The referenced date, if the content refers to a different time — add as "(meaning DATE)" or "(estimated DATE)"
25
+
26
+ ONLY add "(meaning DATE)" when you can derive an actual date:
27
+ - "last week", "yesterday", "next month" → compute and add the date
28
+ - "recently", "a while ago", "soon" → too vague, omit the end date
29
+
30
+ ALWAYS put the date annotation at the END of the observation line.
31
+
32
+ GOOD: (09:15) User will visit parents this weekend. (meaning Jun 17-18, 2025)
33
+ GOOD: (09:15) User's friend had a birthday party last month. (estimated May 2025)
34
+ GOOD: (09:15) User prefers hiking in the mountains.
35
+ BAD: (09:15) User prefers hiking. (meaning Jun 15, 2025) ← no time reference, don't add date
36
+
37
+ If an observation contains MULTIPLE events, split into SEPARATE lines, each with its own date.
38
+
39
+ STATE CHANGES — make supersession explicit:
40
+ - "User will use X (replacing Y)" — not just "User will use X"
41
+ - "User moved to Berlin (no longer in London)"
42
+
43
+ DETAILS TO ALWAYS PRESERVE:
44
+ - Names, handles, usernames (@username, "Dr. Smith")
45
+ - Numbers, counts, quantities (4 items, 3 sessions, $120)
46
+ - Measurements, percentages (5kg, 20% improvement, 85% accuracy)
47
+ - Sequences and orderings (steps 1-5, lucky numbers: 7 14 23)
48
+ - Prices, dates, times, durations
49
+ - Locations and distinguishing attributes
50
+ - User's specific role (presenter, volunteer, organizer — not just "attended")
51
+ - Exact phrasing when unusual ("movement session" for exercise)
52
+
53
+ EXACT NUMBERS — NEVER APPROXIMATE:
54
+
55
+ When the conversation states a specific count, record that EXACT number — do not round, estimate, or substitute a count you see later. If the same quantity appears with different values at different times, record each with its timestamp.
56
+
57
+ BAD: All existing entries bulk-updated to cross_project=1 (50 entries) ← wrong: mixed up with a later count
58
+ GOOD: 43 knowledge entries bulk-updated to cross_project=1 via SQL UPDATE ← exact number from the operation
59
+
60
+ BAD: ~130 test failures
61
+ GOOD: 131 test failures (1902 pass, 131 fail, 1 error across 100 files) ← preserve exact counts
62
+
63
+ BUG FIXES AND CODE CHANGES — HIGH PRIORITY:
64
+
65
+ Every bug fix, code change, or technical decision is important regardless of where it appears in the conversation. Early-session fixes are just as valuable as later ones.
66
+
67
+ For each fix, record:
68
+ - The specific bug/problem (what went wrong)
69
+ - The root cause (why it went wrong)
70
+ - The fix applied (what changed, with file paths and line numbers)
71
+ - The outcome (tests pass, deployed, etc.)
72
+
73
+ BAD: 🟡 Fixed an FTS5 search bug
74
+ GOOD: 🟡 FTS5 was doing exact term matching instead of prefix matching in ltm.ts. Fix: added ftsQuery() function that appends * to each search term for prefix matching. Committed as [hash].
75
+
76
+ ASSISTANT-GENERATED CONTENT — THIS IS CRITICAL:
77
+
78
+ When the assistant produces lists, recommendations, explanations, recipes, schedules, creative content, or any structured output — record EVERY ITEM with its distinguishing details. The user WILL ask about specific items later.
79
+
80
+ BAD: 🟡 Assistant recommended 5 dessert spots in Orlando.
81
+ GOOD: 🟡 Assistant recommended dessert spots: Sugar Factory (Icon Park, giant milkshakes), Wondermade (Sanford, gourmet marshmallows), Gideon's Bakehouse (Disney Springs, cookies), Farris & Foster's (unique flavors), Kilwins (handmade fudge)
82
+
83
+ BAD: 🟡 Assistant listed work-from-home jobs for seniors.
84
+ GOOD: 🟡 Assistant listed 10 WFH jobs for seniors: 1. Virtual assistant, 2. Online tutor, 3. Freelance writer, 4. Social media manager, 5. Customer service rep, 6. Bookkeeper, 7. Transcriptionist, 8. Web designer, 9. Data entry, 10. Consultant
85
+
86
+ BAD: 🟡 Assistant explained refining processes.
87
+ GOOD: 🟡 Assistant explained Lake Charles refinery processes: atmospheric distillation, fluid catalytic cracking (FCC), alkylation, hydrotreating
88
+
89
+ Rules for assistant content:
90
+ - Record EACH item in a list with at least one distinguishing attribute
91
+ - For numbered lists, preserve the EXACT ordering (1st, 2nd, 3rd...)
92
+ - For recipes: preserve specific quantities, ratios, temperatures, times
93
+ - For recommendations: preserve names, locations, prices, key features
94
+ - For creative content (songs, stories, poems): preserve titles, key phrases, character names, structural details
95
+ - For technical explanations: preserve specific values, percentages, formulas, tool/library names
96
+ - Ordered lists must keep their numbering — users ask "what was the 7th item?"
97
+ - Use 🟡 priority but NEVER skip assistant-generated details to save space
98
+
99
+ ENUMERATABLE ENTITIES — always flag for cross-session aggregation:
100
+ When the user mentions attending events, buying things, meeting people, completing tasks — mark with entity type so these can be aggregated across sessions:
101
+ 🔴 [event-attended] User attended Rachel+Mike's wedding (vineyard in Napa, Aug 12, 2023)
102
+ 🔴 [item-purchased] User bought Sony WH-1000XM5 headphones ($280, replaced old Bose)
103
+ This makes it possible to answer "how many weddings did I attend?" by aggregating across sessions.
104
+
105
+ PRIORITY LEVELS:
106
+ - 🔴 High: user assertions, stated facts, preferences, goals, enumeratable entities
107
+ - 🟡 Medium: questions asked, context, assistant-generated content with full detail
108
+ - 🟢 Low: minor conversational context, greetings, acknowledgments
109
+
110
+ OUTPUT FORMAT — output ONLY observations, no preamble:
111
+
112
+ <observations>
113
+ Date: Jan 15, 2026
114
+ * 🔴 (09:15) User stated has two kids: Emma (12) and Jake (9)
115
+ * 🔴 (09:16) User's anniversary is March 15
116
+ * 🟡 (09:20) User asked how to optimize database queries
117
+ * 🔴 [event-attended] (10:00) User attended company holiday party as a presenter (gave talk on microservices)
118
+ * 🔴 (11:30) User will visit parents this weekend. (meaning Jan 17-18, 2026)
119
+ * 🟡 (14:00) Agent debugging auth issue — found missing null check in auth.ts:45, applied fix, tests pass
120
+ * 🟡 (14:30) Assistant recommended 5 hotels: 1. Grand Plaza (near station, $180), 2. Seaside Inn (pet-friendly, $120), 3. Mountain Lodge (pool, free breakfast, $95), 4. Harbor View (historic, walkable, $150), 5. Zen Garden (quietest, spa, $200)
121
+ * 🔴 (15:00) User switched from Python to TypeScript for the project (no longer using Python)
122
+ </observations>`;
123
+
124
+ export function distillationUser(input: {
125
+ priorObservations?: string;
126
+ date: string;
127
+ messages: string;
128
+ }): string {
129
+ const context = input.priorObservations
130
+ ? `Previous observations (do NOT repeat these — your new observations will be appended):\n${input.priorObservations}\n\n---`
131
+ : "This is the beginning of the session.";
132
+ return `${context}
133
+
134
+ Session date: ${input.date}
135
+
136
+ Conversation to observe:
137
+
138
+ ${input.messages}
139
+
140
+ Extract new observations. Output ONLY an <observations> block.`;
141
+ }
142
+
143
+ export const RECURSIVE_SYSTEM = `You are a memory reflector. You are given a set of observations from multiple conversation segments. Your job is to reorganize, streamline, and compress them into a single refined observation log that will become the agent's entire memory going forward.
144
+
145
+ IMPORTANT: Your reflections ARE the entirety of the assistant's memory. Any information you omit is permanently forgotten. Do not leave out anything important.
146
+
147
+ REFLECTION RULES:
148
+ - Preserve ALL dates and timestamps — temporal context is critical
149
+ - Condense older observations more aggressively; retain more detail for recent ones
150
+ - Combine related items (e.g., "agent called view tool 5 times on file x" → single line)
151
+ - Merge duplicate facts, keeping the most specific version
152
+ - Drop observations superseded by later info (if value changed, keep only final value)
153
+ - When consolidating, USER ASSERTIONS take precedence over questions about the same topic
154
+ - Preserve all enumeratable entities [entity-type] — these are needed for aggregation questions
155
+ - For enumeratable entities spanning multiple segments, create an explicit aggregation:
156
+ 🔴 [event-attended] User attended 3 weddings total: Rachel+Mike (vineyard, Aug 2023), Emily+Sarah (garden, Sep 2023), Jen+Tom (Oct 8, 2023)
157
+
158
+ EXACT NUMBERS: When two segments report different numbers for what seems like the same thing, keep the number from the earlier/original observation — it's likely the correct one from the actual event. Later references may be from memory or approximation.
159
+
160
+ EARLY-SESSION CONTENT: Bug fixes, code changes, and decisions from the start of a session are just as important as later work. Never drop them just because the segment is short or old. If the first segment contains a specific bug fix with file paths and root cause, it MUST survive into the reflection.
161
+
162
+ Keep the same format: dated sections with priority-tagged observations.
163
+
164
+ Output ONLY an <observations> block with the consolidated observations.`;
165
+
166
+ export function recursiveUser(
167
+ distillations: Array<{ observations: string }>,
168
+ ): string {
169
+ const entries = distillations.map(
170
+ (d, i) => `Segment ${i + 1}:\n${d.observations}`,
171
+ );
172
+ return `Observation segments to consolidate (chronological order):
173
+
174
+ ${entries.join("\n\n---\n\n")}`;
175
+ }
176
+
177
+ export const CURATOR_SYSTEM = `You are a long-term memory curator. Your job is to extract durable knowledge from a conversation that should persist across sessions.
178
+
179
+ Focus on knowledge that will remain true and useful beyond the current task:
180
+ - User preferences and working style
181
+ - Architectural decisions and their rationale
182
+ - Project conventions and patterns
183
+ - Environment setup details
184
+ - Recurring gotchas or constraints
185
+ - Important relationships between components
186
+
187
+ Do NOT extract:
188
+ - Task-specific details (file currently being edited, current bug being fixed)
189
+ - Temporary state (current branch, in-progress work)
190
+ - Information that will change frequently
191
+
192
+ Produce a JSON array of operations:
193
+ [
194
+ {
195
+ "op": "create",
196
+ "category": "decision" | "pattern" | "preference" | "architecture" | "gotcha",
197
+ "title": "Short descriptive title",
198
+ "content": "Detailed knowledge entry",
199
+ "scope": "project" | "global",
200
+ "crossProject": true
201
+ },
202
+ {
203
+ "op": "update",
204
+ "id": "existing-entry-id",
205
+ "content": "Updated content",
206
+ "confidence": 0.0-1.0
207
+ },
208
+ {
209
+ "op": "delete",
210
+ "id": "existing-entry-id",
211
+ "reason": "Why this is no longer relevant"
212
+ }
213
+ ]
214
+
215
+ If nothing warrants extraction, return an empty array: []
216
+
217
+ Output ONLY valid JSON. No markdown fences, no explanation, no preamble.`;
218
+
219
+ export function curatorUser(input: {
220
+ messages: string;
221
+ existing: Array<{
222
+ id: string;
223
+ category: string;
224
+ title: string;
225
+ content: string;
226
+ }>;
227
+ }): string {
228
+ const existing = input.existing.length
229
+ ? `Existing knowledge entries (you may update or delete these):\n${input.existing.map((e) => `- [${e.id}] (${e.category}) ${e.title}: ${e.content}`).join("\n")}`
230
+ : "No existing knowledge entries.";
231
+ return `${existing}
232
+
233
+ ---
234
+ Recent conversation to extract knowledge from:
235
+
236
+ ${input.messages}`;
237
+ }
238
+
239
+ // Format distillations for injection into the message context.
240
+ // Observations are plain event-log text — inject them directly under a header.
241
+ export function formatDistillations(
242
+ distillations: Array<{
243
+ observations: string;
244
+ generation: number;
245
+ }>,
246
+ ): string {
247
+ if (!distillations.length) return "";
248
+
249
+ const meta = distillations.filter((d) => d.generation > 0);
250
+ const recent = distillations.filter((d) => d.generation === 0);
251
+ const sections: string[] = ["## Session History"];
252
+
253
+ if (meta.length) {
254
+ sections.push("### Earlier Work (summarized)");
255
+ for (const d of meta) {
256
+ sections.push(d.observations.trim());
257
+ }
258
+ }
259
+
260
+ if (recent.length) {
261
+ sections.push("### Recent Work (distilled)");
262
+ for (const d of recent) {
263
+ sections.push(d.observations.trim());
264
+ }
265
+ }
266
+
267
+ return sections.join("\n\n");
268
+ }
269
+
270
+ export function formatKnowledge(
271
+ entries: Array<{ category: string; title: string; content: string }>,
272
+ ): string {
273
+ if (!entries.length) return "";
274
+
275
+ const grouped: Record<string, Array<{ title: string; content: string }>> = {};
276
+ for (const e of entries) {
277
+ const group = grouped[e.category] ?? (grouped[e.category] = []);
278
+ group.push(e);
279
+ }
280
+
281
+ const children: Root["children"] = [h(2, "Long-term Knowledge")];
282
+ for (const [category, items] of Object.entries(grouped)) {
283
+ children.push(h(3, category.charAt(0).toUpperCase() + category.slice(1)));
284
+ children.push(
285
+ ul(
286
+ items.map((i) =>
287
+ liph(strong(inline(i.title)), t(": " + inline(i.content))),
288
+ ),
289
+ ),
290
+ );
291
+ }
292
+
293
+ return serialize(root(...children));
294
+ }
package/src/reflect.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { tool } from "@opencode-ai/plugin/tool";
2
+ import * as temporal from "./temporal";
3
+ import * as ltm from "./ltm";
4
+ import { db, ensureProject } from "./db";
5
+ import { serialize, inline, h, p, ul, lip, liph, t, root } from "./markdown";
6
+
7
+ type Distillation = {
8
+ id: string;
9
+ observations: string;
10
+ generation: number;
11
+ created_at: number;
12
+ session_id: string;
13
+ };
14
+
15
+ function searchDistillations(input: {
16
+ projectPath: string;
17
+ query: string;
18
+ sessionID?: string;
19
+ limit?: number;
20
+ }): Distillation[] {
21
+ const pid = ensureProject(input.projectPath);
22
+ const limit = input.limit ?? 10;
23
+ // Search distillation narratives and facts with LIKE since we don't have FTS on them
24
+ const terms = input.query
25
+ .toLowerCase()
26
+ .split(/\s+/)
27
+ .filter((t) => t.length > 2);
28
+ if (!terms.length) return [];
29
+
30
+ const conditions = terms
31
+ .map(() => "LOWER(observations) LIKE ?")
32
+ .join(" AND ");
33
+ const params: string[] = [];
34
+ for (const term of terms) {
35
+ params.push(`%${term}%`);
36
+ }
37
+
38
+ const query = input.sessionID
39
+ ? `SELECT id, observations, generation, created_at, session_id FROM distillations WHERE project_id = ? AND session_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`
40
+ : `SELECT id, observations, generation, created_at, session_id FROM distillations WHERE project_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`;
41
+ const allParams = input.sessionID
42
+ ? [pid, input.sessionID, ...params, limit]
43
+ : [pid, ...params, limit];
44
+
45
+ return db()
46
+ .query(query)
47
+ .all(...allParams) as Distillation[];
48
+ }
49
+
50
+ function formatResults(input: {
51
+ temporalResults: temporal.TemporalMessage[];
52
+ distillationResults: Distillation[];
53
+ knowledgeResults: ltm.KnowledgeEntry[];
54
+ }): string {
55
+ const children: ReturnType<typeof root>["children"] = [];
56
+
57
+ if (input.knowledgeResults.length) {
58
+ children.push(h(2, "Long-term Knowledge"));
59
+ children.push(
60
+ ul(
61
+ input.knowledgeResults.map((k) =>
62
+ liph(t(`[${k.category}] ${inline(k.title)}: ${inline(k.content)}`)),
63
+ ),
64
+ ),
65
+ );
66
+ }
67
+
68
+ if (input.distillationResults.length) {
69
+ children.push(h(2, "Distilled History"));
70
+ for (const d of input.distillationResults) {
71
+ children.push(p(inline(d.observations)));
72
+ }
73
+ }
74
+
75
+ if (input.temporalResults.length) {
76
+ children.push(h(2, "Raw Message Matches"));
77
+ children.push(
78
+ ul(
79
+ input.temporalResults.map((m) => {
80
+ const preview =
81
+ m.content.length > 500
82
+ ? m.content.slice(0, 500) + "..."
83
+ : m.content;
84
+ return lip(
85
+ `[${m.role}] (session: ${m.session_id.slice(0, 8)}...) ${inline(preview)}`,
86
+ );
87
+ }),
88
+ ),
89
+ );
90
+ }
91
+
92
+ if (!children.length) return "No results found for this query.";
93
+ return serialize(root(...children));
94
+ }
95
+
96
+ export function createRecallTool(projectPath: string): ReturnType<typeof tool> {
97
+ return tool({
98
+ description:
99
+ "Search your persistent memory for this project. Your visible context is a trimmed window — older messages, decisions, and details may not be visible to you even within the current session. Use this tool whenever you need information that isn't in your current context: file paths, past decisions, user preferences, prior approaches, or anything from earlier in this conversation or previous sessions. Always prefer recall over assuming you don't have the information. Searches long-term knowledge, distilled history, and raw message archives.",
100
+ args: {
101
+ query: tool.schema
102
+ .string()
103
+ .describe(
104
+ "What to search for — be specific. Include keywords, file names, or concepts.",
105
+ ),
106
+ scope: tool.schema
107
+ .enum(["all", "session", "project", "knowledge"])
108
+ .optional()
109
+ .describe(
110
+ "Search scope: 'all' (default) searches everything, 'session' searches current session only, 'project' searches all sessions in this project, 'knowledge' searches only long-term knowledge.",
111
+ ),
112
+ },
113
+ async execute(args, context) {
114
+ const scope = args.scope ?? "all";
115
+ const sid = context.sessionID;
116
+
117
+ const temporalResults =
118
+ scope === "knowledge"
119
+ ? []
120
+ : temporal.search({
121
+ projectPath,
122
+ query: args.query,
123
+ sessionID: scope === "session" ? sid : undefined,
124
+ limit: 10,
125
+ });
126
+
127
+ const distillationResults =
128
+ scope === "knowledge"
129
+ ? []
130
+ : searchDistillations({
131
+ projectPath,
132
+ query: args.query,
133
+ sessionID: scope === "session" ? sid : undefined,
134
+ limit: 5,
135
+ });
136
+
137
+ const knowledgeResults =
138
+ scope === "session"
139
+ ? []
140
+ : ltm.search({
141
+ query: args.query,
142
+ projectPath,
143
+ limit: 10,
144
+ });
145
+
146
+ return formatResults({
147
+ temporalResults,
148
+ distillationResults,
149
+ knowledgeResults,
150
+ });
151
+ },
152
+ });
153
+ }
@@ -0,0 +1,230 @@
1
+ import { db, ensureProject } from "./db";
2
+ import type { Message, Part } from "@opencode-ai/sdk";
3
+
4
+ // Estimate token count from text length (rough: 1 token ≈ 4 chars)
5
+ function estimate(text: string): number {
6
+ return Math.ceil(text.length / 4);
7
+ }
8
+
9
+ function partsToText(parts: Part[]): string {
10
+ const chunks: string[] = [];
11
+ for (const part of parts) {
12
+ if (part.type === "text") chunks.push(part.text);
13
+ else if (part.type === "reasoning" && part.text)
14
+ chunks.push(`[reasoning] ${part.text}`);
15
+ else if (part.type === "tool" && part.state.status === "completed")
16
+ chunks.push(`[tool:${part.tool}] ${part.state.output}`);
17
+ }
18
+ return chunks.join("\n");
19
+ }
20
+
21
+ function messageMetadata(info: Message, parts: Part[]): string {
22
+ const meta: Record<string, unknown> = {};
23
+ if (info.role === "user") {
24
+ meta.agent = info.agent;
25
+ meta.model = info.model;
26
+ } else {
27
+ meta.modelID = info.modelID;
28
+ meta.providerID = info.providerID;
29
+ meta.mode = info.mode;
30
+ }
31
+ const tools = parts
32
+ .filter((p) => p.type === "tool")
33
+ .map((p) => (p as Extract<Part, { type: "tool" }>).tool);
34
+ if (tools.length) meta.tools = tools;
35
+ return JSON.stringify(meta);
36
+ }
37
+
38
+ export function store(input: {
39
+ projectPath: string;
40
+ info: Message;
41
+ parts: Part[];
42
+ }) {
43
+ const pid = ensureProject(input.projectPath);
44
+ const content = partsToText(input.parts);
45
+ if (!content.trim()) return;
46
+
47
+ const existing = db()
48
+ .query("SELECT id FROM temporal_messages WHERE id = ?")
49
+ .get(input.info.id);
50
+ if (existing) {
51
+ db()
52
+ .query(
53
+ "UPDATE temporal_messages SET content = ?, tokens = ?, metadata = ? WHERE id = ?",
54
+ )
55
+ .run(
56
+ content,
57
+ estimate(content),
58
+ messageMetadata(input.info, input.parts),
59
+ input.info.id,
60
+ );
61
+ return;
62
+ }
63
+
64
+ db()
65
+ .query(
66
+ `INSERT INTO temporal_messages (id, project_id, session_id, role, content, tokens, distilled, created_at, metadata)
67
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`,
68
+ )
69
+ .run(
70
+ input.info.id,
71
+ pid,
72
+ input.info.sessionID,
73
+ input.info.role,
74
+ content,
75
+ estimate(content),
76
+ input.info.time.created,
77
+ messageMetadata(input.info, input.parts),
78
+ );
79
+ }
80
+
81
+ export type TemporalMessage = {
82
+ id: string;
83
+ project_id: string;
84
+ session_id: string;
85
+ role: string;
86
+ content: string;
87
+ tokens: number;
88
+ distilled: number;
89
+ created_at: number;
90
+ metadata: string;
91
+ };
92
+
93
+ export function undistilled(
94
+ projectPath: string,
95
+ sessionID?: string,
96
+ ): TemporalMessage[] {
97
+ const pid = ensureProject(projectPath);
98
+ const query = sessionID
99
+ ? "SELECT * FROM temporal_messages WHERE project_id = ? AND session_id = ? AND distilled = 0 ORDER BY created_at ASC"
100
+ : "SELECT * FROM temporal_messages WHERE project_id = ? AND distilled = 0 ORDER BY created_at ASC";
101
+ const params = sessionID ? [pid, sessionID] : [pid];
102
+ return db()
103
+ .query(query)
104
+ .all(...params) as TemporalMessage[];
105
+ }
106
+
107
+ export function bySession(
108
+ projectPath: string,
109
+ sessionID: string,
110
+ ): TemporalMessage[] {
111
+ const pid = ensureProject(projectPath);
112
+ return db()
113
+ .query(
114
+ "SELECT * FROM temporal_messages WHERE project_id = ? AND session_id = ? ORDER BY created_at ASC",
115
+ )
116
+ .all(pid, sessionID) as TemporalMessage[];
117
+ }
118
+
119
+ export function markDistilled(ids: string[]) {
120
+ if (!ids.length) return;
121
+ const placeholders = ids.map(() => "?").join(",");
122
+ db()
123
+ .query(
124
+ `UPDATE temporal_messages SET distilled = 1 WHERE id IN (${placeholders})`,
125
+ )
126
+ .run(...ids);
127
+ }
128
+
129
+ // Sanitize a natural-language query for FTS5 MATCH.
130
+ // FTS5 treats punctuation as operators: - = NOT, . = column filter, " = phrase, etc.
131
+ // Strip everything except word chars and whitespace, split into tokens, append * for
132
+ // prefix matching. Exported so ltm.ts can reuse it instead of maintaining a duplicate.
133
+ export function ftsQuery(raw: string): string {
134
+ const words = raw
135
+ .replace(/[^\w\s]/g, " ")
136
+ .split(/\s+/)
137
+ .filter(Boolean);
138
+ if (!words.length) return '""'; // empty match-nothing sentinel
139
+ return words.map((w) => `${w}*`).join(" ");
140
+ }
141
+
142
+ // LIKE-based fallback for when FTS5 fails unexpectedly.
143
+ function searchLike(input: {
144
+ pid: string;
145
+ query: string;
146
+ sessionID?: string;
147
+ limit: number;
148
+ }): TemporalMessage[] {
149
+ const terms = input.query
150
+ .toLowerCase()
151
+ .split(/\s+/)
152
+ .filter((t) => t.length > 2);
153
+ if (!terms.length) return [];
154
+ const conditions = terms.map(() => "LOWER(content) LIKE ?").join(" AND ");
155
+ const likeParams = terms.map((t) => `%${t}%`);
156
+ const query = input.sessionID
157
+ ? `SELECT * FROM temporal_messages WHERE project_id = ? AND session_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`
158
+ : `SELECT * FROM temporal_messages WHERE project_id = ? AND ${conditions} ORDER BY created_at DESC LIMIT ?`;
159
+ const params = input.sessionID
160
+ ? [input.pid, input.sessionID, ...likeParams, input.limit]
161
+ : [input.pid, ...likeParams, input.limit];
162
+ return db()
163
+ .query(query)
164
+ .all(...params) as TemporalMessage[];
165
+ }
166
+
167
+ export function search(input: {
168
+ projectPath: string;
169
+ query: string;
170
+ sessionID?: string;
171
+ limit?: number;
172
+ }): TemporalMessage[] {
173
+ const pid = ensureProject(input.projectPath);
174
+ const limit = input.limit ?? 20;
175
+ const q = ftsQuery(input.query);
176
+ const ftsSQL = input.sessionID
177
+ ? `SELECT m.* FROM temporal_messages m
178
+ JOIN temporal_fts f ON m.rowid = f.rowid
179
+ WHERE f.content MATCH ? AND m.project_id = ? AND m.session_id = ?
180
+ ORDER BY rank LIMIT ?`
181
+ : `SELECT m.* FROM temporal_messages m
182
+ JOIN temporal_fts f ON m.rowid = f.rowid
183
+ WHERE f.content MATCH ? AND m.project_id = ?
184
+ ORDER BY rank LIMIT ?`;
185
+ const params = input.sessionID
186
+ ? [q, pid, input.sessionID, limit]
187
+ : [q, pid, limit];
188
+ try {
189
+ return db()
190
+ .query(ftsSQL)
191
+ .all(...params) as TemporalMessage[];
192
+ } catch {
193
+ // FTS5 still choked (edge case) — fall back to LIKE search
194
+ return searchLike({
195
+ pid,
196
+ query: input.query,
197
+ sessionID: input.sessionID,
198
+ limit,
199
+ });
200
+ }
201
+ }
202
+
203
+ export function count(projectPath: string, sessionID?: string): number {
204
+ const pid = ensureProject(projectPath);
205
+ const query = sessionID
206
+ ? "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ? AND session_id = ?"
207
+ : "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ?";
208
+ const params = sessionID ? [pid, sessionID] : [pid];
209
+ return (
210
+ db()
211
+ .query(query)
212
+ .get(...params) as { count: number }
213
+ ).count;
214
+ }
215
+
216
+ export function undistilledCount(
217
+ projectPath: string,
218
+ sessionID?: string,
219
+ ): number {
220
+ const pid = ensureProject(projectPath);
221
+ const query = sessionID
222
+ ? "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ? AND session_id = ? AND distilled = 0"
223
+ : "SELECT COUNT(*) as count FROM temporal_messages WHERE project_id = ? AND distilled = 0";
224
+ const params = sessionID ? [pid, sessionID] : [pid];
225
+ return (
226
+ db()
227
+ .query(query)
228
+ .get(...params) as { count: number }
229
+ ).count;
230
+ }