Skip to content

Commit 57de902

Browse files
committed
feat: add ctx_search unified search tool across memories, facts, and message history
Introduces ctx_search — a single tool for searching across all three data layers: project memories (semantic + FTS), session facts (text match), and raw conversation history (FTS5 index). - New message_history_fts FTS5 table with incremental lazy indexing per session (indexes user/assistant text only, skips tool calls and notifications) - Three-tier merged ranking: memories first (highest signal), then facts, then raw message hits with ordinal for ctx_expand follow-up - Removed search action from ctx_memory (now purely write/delete) - Updated prompt guidance to reference ctx_search - 424 tests pass, typecheck clean
1 parent 104329f commit 57de902

24 files changed

Lines changed: 1044 additions & 449 deletions

src/agents/magic-context-prompt.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ Use \`ctx_reduce\` to manage context size. It supports one operation:
1919
- \`drop\`: Remove entirely (best for tool outputs you already acted on).
2020
Syntax: "3-5", "1,2,9", or "1-5,8,12-15". Last ${protectedTags} tags are protected.
2121
Use \`ctx_note\` for deferred intentions — things to tackle later, not right now. NOT for task tracking (use todos). Notes survive context compression and you'll be reminded at natural work boundaries (after commits, historian runs, todo completion).
22-
Use \`ctx_memory\` to manage cross-session project memories. Write new memories, delete stale ones, or search stored memories by category. Memories persist across sessions and are automatically injected into new sessions.
22+
Use \`ctx_memory\` to manage cross-session project memories. Write new memories or delete stale ones. Memories persist across sessions and are automatically injected into new sessions.
23+
Use \`ctx_search\` to search across project memories, session facts, and conversation history from one query.
2324
Use \`ctx_expand\` to decompress a compartment range to see the original conversation transcript. Use \`start\`/\`end\` from \`<compartment start=N end=M>\` attributes. Returns the compacted U:/A: transcript for that message range, capped at ~15K tokens.
2425
NEVER drop large ranges blindly (e.g., "1-50"). Review each tag before deciding.
2526
NEVER drop user messages — they are short and will be summarized by compartmentalization automatically. Dropping them loses context the historian needs.
@@ -29,7 +30,8 @@ Before your turn finishes, consider using \`ctx_reduce\` to drop large tool outp
2930
/** Intro when ctx_reduce is disabled — no drop guidance, no ctx_reduce references. */
3031
const BASE_INTRO_NO_REDUCE = `Messages and tool outputs are tagged with §N§ identifiers (e.g., §1§, §42§).
3132
Use \`ctx_note\` for deferred intentions — things to tackle later, not right now. NOT for task tracking (use todos). Notes survive context compression and you'll be reminded at natural work boundaries (after commits, historian runs, todo completion).
32-
Use \`ctx_memory\` to manage cross-session project memories. Write new memories, delete stale ones, or search stored memories by category. Memories persist across sessions and are automatically injected into new sessions.
33+
Use \`ctx_memory\` to manage cross-session project memories. Write new memories or delete stale ones. Memories persist across sessions and are automatically injected into new sessions.
34+
Use \`ctx_search\` to search across project memories, session facts, and conversation history from one query.
3335
Use \`ctx_expand\` to decompress a compartment range to see the original conversation transcript. Use \`start\`/\`end\` from \`<compartment start=N end=M>\` attributes. Returns the compacted U:/A: transcript for that message range, capped at ~15K tokens.`;
3436

3537
const SISYPHUS_SECTION = `

src/features/magic-context/dreamer/task-prompts.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ You run during scheduled dream windows to maintain a project's cross-session mem
99
1010
**Memory operations** (ctx_memory with extended dreamer actions):
1111
- \`action="list"\` — browse all active memories, optionally filter by category
12-
- \`action="search", query="..."\` — semantic search across memories
1312
- \`action="update", id=N, content="..."\` — rewrite a memory's content
1413
- \`action="merge", ids=[N,M,...], content="...", category="..."\` — consolidate duplicates into one canonical memory
1514
- \`action="archive", id=N, reason="..."\` — archive a stale memory with provenance
@@ -97,7 +96,7 @@ Check verifiable memories against actual repository state. Update stale wording,
9796
### Verification examples
9897
- Memory: "compartment_token_budget defaults to 20000" → grep schema for \`compartment_token_budget\`, check \`.default(...)\`
9998
- Memory: "Durable state lives in ~/.local/share/opencode/storage/plugin/magic-context/context.db" → check storage-db.ts for the path construction
100-
- Memory: "ctx_memory search combines semantic and FTS" → grep for ctx_memory tool definition, verify search action exists
99+
- Memory: "ctx_search searches memories, facts, and history" → grep for ctx_search tool definition and unified search implementation
101100
102101
### Success criteria
103102
- All CONFIG_DEFAULTS memories match actual schema defaults.

src/features/magic-context/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from "./dreamer";
44
export * from "./memory";
55
export * from "./range-parser";
66
export * from "./scheduler";
7+
export * from "./search";
78
export * from "./sidekick";
89
export * from "./storage";
910
export * from "./tagger";

src/features/magic-context/memory/storage-memory-fts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ function getSearchStatement(db: Database): PreparedStatement {
2929
* This wraps each whitespace-delimited token in double quotes so special
3030
* characters are treated as literal content rather than query syntax.
3131
*/
32-
function sanitizeFtsQuery(query: string): string {
32+
export function sanitizeFtsQuery(query: string): string {
3333
const tokens = query.split(/\s+/).filter((token) => token.length > 0);
3434
if (tokens.length === 0) return "";
3535

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { Database } from "bun:sqlite";
2+
import {
3+
cleanUserText,
4+
extractTexts,
5+
hasMeaningfulUserText,
6+
} from "../../hooks/magic-context/read-session-chunk";
7+
import type { RawMessage } from "../../hooks/magic-context/read-session-raw";
8+
import { removeSystemReminders } from "../../shared/system-directive";
9+
10+
type PreparedStatement = ReturnType<Database["prepare"]>;
11+
12+
interface MessageHistoryIndexRow {
13+
last_indexed_ordinal?: number;
14+
}
15+
16+
const lastIndexedStatements = new WeakMap<Database, PreparedStatement>();
17+
const insertMessageStatements = new WeakMap<Database, PreparedStatement>();
18+
const upsertIndexStatements = new WeakMap<Database, PreparedStatement>();
19+
const deleteFtsStatements = new WeakMap<Database, PreparedStatement>();
20+
const deleteIndexStatements = new WeakMap<Database, PreparedStatement>();
21+
22+
function normalizeIndexText(text: string): string {
23+
return text.replace(/\s+/g, " ").trim();
24+
}
25+
26+
function getLastIndexedStatement(db: Database): PreparedStatement {
27+
let stmt = lastIndexedStatements.get(db);
28+
if (!stmt) {
29+
stmt = db.prepare(
30+
"SELECT last_indexed_ordinal FROM message_history_index WHERE session_id = ?",
31+
);
32+
lastIndexedStatements.set(db, stmt);
33+
}
34+
return stmt;
35+
}
36+
37+
function getInsertMessageStatement(db: Database): PreparedStatement {
38+
let stmt = insertMessageStatements.get(db);
39+
if (!stmt) {
40+
stmt = db.prepare(
41+
"INSERT INTO message_history_fts (session_id, message_ordinal, message_id, role, content) VALUES (?, ?, ?, ?, ?)",
42+
);
43+
insertMessageStatements.set(db, stmt);
44+
}
45+
return stmt;
46+
}
47+
48+
function getUpsertIndexStatement(db: Database): PreparedStatement {
49+
let stmt = upsertIndexStatements.get(db);
50+
if (!stmt) {
51+
stmt = db.prepare(
52+
"INSERT INTO message_history_index (session_id, last_indexed_ordinal, updated_at) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET last_indexed_ordinal = excluded.last_indexed_ordinal, updated_at = excluded.updated_at",
53+
);
54+
upsertIndexStatements.set(db, stmt);
55+
}
56+
return stmt;
57+
}
58+
59+
function getDeleteFtsStatement(db: Database): PreparedStatement {
60+
let stmt = deleteFtsStatements.get(db);
61+
if (!stmt) {
62+
stmt = db.prepare("DELETE FROM message_history_fts WHERE session_id = ?");
63+
deleteFtsStatements.set(db, stmt);
64+
}
65+
return stmt;
66+
}
67+
68+
function getDeleteIndexStatement(db: Database): PreparedStatement {
69+
let stmt = deleteIndexStatements.get(db);
70+
if (!stmt) {
71+
stmt = db.prepare("DELETE FROM message_history_index WHERE session_id = ?");
72+
deleteIndexStatements.set(db, stmt);
73+
}
74+
return stmt;
75+
}
76+
77+
function getLastIndexedOrdinal(db: Database, sessionId: string): number {
78+
const row = getLastIndexedStatement(db).get(sessionId) as MessageHistoryIndexRow | null;
79+
return typeof row?.last_indexed_ordinal === "number" ? row.last_indexed_ordinal : 0;
80+
}
81+
82+
function clearIndexedMessages(db: Database, sessionId: string): void {
83+
getDeleteFtsStatement(db).run(sessionId);
84+
getDeleteIndexStatement(db).run(sessionId);
85+
}
86+
87+
function getIndexableContent(role: string, parts: unknown[]): string {
88+
if (role === "user") {
89+
if (!hasMeaningfulUserText(parts)) {
90+
return "";
91+
}
92+
93+
return extractTexts(parts)
94+
.map(cleanUserText)
95+
.map(normalizeIndexText)
96+
.filter((text) => text.length > 0)
97+
.join(" / ");
98+
}
99+
100+
if (role === "assistant") {
101+
return extractTexts(parts)
102+
.map(removeSystemReminders)
103+
.map(normalizeIndexText)
104+
.filter((text) => text.length > 0)
105+
.join(" / ");
106+
}
107+
108+
return "";
109+
}
110+
111+
export function ensureMessagesIndexed(
112+
db: Database,
113+
sessionId: string,
114+
readMessages: (sessionId: string) => RawMessage[],
115+
): void {
116+
const messages = readMessages(sessionId);
117+
118+
if (messages.length === 0) {
119+
db.transaction(() => clearIndexedMessages(db, sessionId))();
120+
return;
121+
}
122+
123+
let lastIndexedOrdinal = getLastIndexedOrdinal(db, sessionId);
124+
if (lastIndexedOrdinal > messages.length) {
125+
db.transaction(() => clearIndexedMessages(db, sessionId))();
126+
lastIndexedOrdinal = 0;
127+
}
128+
129+
if (lastIndexedOrdinal >= messages.length) {
130+
return;
131+
}
132+
133+
const messagesToInsert = messages
134+
.filter((message) => message.ordinal > lastIndexedOrdinal)
135+
.filter((message) => message.role === "user" || message.role === "assistant")
136+
.map((message) => ({
137+
ordinal: message.ordinal,
138+
id: message.id,
139+
role: message.role,
140+
content: getIndexableContent(message.role, message.parts),
141+
}))
142+
.filter((message) => message.content.length > 0);
143+
144+
const now = Date.now();
145+
db.transaction(() => {
146+
const insertMessage = getInsertMessageStatement(db);
147+
for (const message of messagesToInsert) {
148+
insertMessage.run(
149+
sessionId,
150+
message.ordinal,
151+
message.id,
152+
message.role,
153+
message.content,
154+
);
155+
}
156+
157+
getUpsertIndexStatement(db).run(sessionId, messages.length, now);
158+
})();
159+
}

0 commit comments

Comments
 (0)