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/LICENSE +21 -0
- package/README.md +123 -0
- package/package.json +47 -0
- package/src/config.ts +54 -0
- package/src/curator.ts +154 -0
- package/src/db.ts +198 -0
- package/src/distillation.ts +426 -0
- package/src/gradient.ts +541 -0
- package/src/index.ts +324 -0
- package/src/ltm.ts +186 -0
- package/src/markdown.ts +81 -0
- package/src/prompt.ts +294 -0
- package/src/reflect.ts +153 -0
- package/src/temporal.ts +230 -0
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
|
+
}
|
package/src/temporal.ts
ADDED
|
@@ -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
|
+
}
|