opencode-episodic-memory 0.3.0 → 0.3.1
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/README.md +23 -19
- package/package.json +1 -1
- package/plugin/episodic-memory.ts +43 -22
- package/skills/remembering-conversations/SKILL.md +14 -8
- package/src/format.ts +15 -1
- package/src/store.ts +65 -0
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ tends to match injected boilerplate on this corpus.
|
|
|
35
35
|
## Install
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
opencode plugin opencode-episodic-memory@0.3.
|
|
38
|
+
opencode plugin opencode-episodic-memory@0.3.1 -g
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
This adds the plugin to your OpenCode config (`-g` = global config; omit it
|
|
@@ -48,7 +48,7 @@ Or edit `~/.config/opencode/opencode.json` manually:
|
|
|
48
48
|
|
|
49
49
|
```jsonc
|
|
50
50
|
{
|
|
51
|
-
"plugin": ["opencode-episodic-memory@0.3.
|
|
51
|
+
"plugin": ["opencode-episodic-memory@0.3.1"]
|
|
52
52
|
}
|
|
53
53
|
```
|
|
54
54
|
|
|
@@ -75,13 +75,13 @@ OpenCode has downloaded the plugin (i.e. after first launch), copy it out of
|
|
|
75
75
|
the package cache (the path contains your pinned version):
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.3.
|
|
78
|
+
cp -r ~/.cache/opencode/packages/opencode-episodic-memory@0.3.1/node_modules/opencode-episodic-memory/skills/remembering-conversations ~/.config/opencode/skills/
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
Then backfill existing history and restart OpenCode:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
bunx opencode-episodic-memory@0.3.
|
|
84
|
+
bunx opencode-episodic-memory@0.3.1 sync
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
## CLI
|
|
@@ -90,16 +90,16 @@ The package ships an `opencode-episodic` binary (requires `bun` on PATH).
|
|
|
90
90
|
Invoke it through the package spec — pin it to match your plugin version:
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
|
-
bunx opencode-episodic-memory@0.3.
|
|
94
|
-
bunx opencode-episodic-memory@0.3.
|
|
95
|
-
bunx opencode-episodic-memory@0.3.
|
|
96
|
-
bunx opencode-episodic-memory@0.3.
|
|
97
|
-
bunx opencode-episodic-memory@0.3.
|
|
98
|
-
bunx opencode-episodic-memory@0.3.
|
|
99
|
-
bunx opencode-episodic-memory@0.3.
|
|
93
|
+
bunx opencode-episodic-memory@0.3.1 sync [--force] # index new/changed sessions
|
|
94
|
+
bunx opencode-episodic-memory@0.3.1 search "query" # semantic (vector) search
|
|
95
|
+
bunx opencode-episodic-memory@0.3.1 search q --text "terms" # lexical BM25 (all terms AND-matched, token-based)
|
|
96
|
+
bunx opencode-episodic-memory@0.3.1 search q --hybrid # fuse vector + BM25 (RRF; opt-in)
|
|
97
|
+
bunx opencode-episodic-memory@0.3.1 search q --after 2026-07-01 --limit 5
|
|
98
|
+
bunx opencode-episodic-memory@0.3.1 read <session-id> # full transcript (live store)
|
|
99
|
+
bunx opencode-episodic-memory@0.3.1 read <id> --indexed # local indexed excerpts
|
|
100
100
|
bun run src/cli.ts read <id> --indexed --source laptop # remote indexed excerpts (development/source checkout)
|
|
101
|
-
bunx opencode-episodic-memory@0.3.
|
|
102
|
-
bunx opencode-episodic-memory@0.3.
|
|
101
|
+
bunx opencode-episodic-memory@0.3.1 stats # index statistics
|
|
102
|
+
bunx opencode-episodic-memory@0.3.1 doctor # diagnose setup
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
`--after`/`--before` take `YYYY-MM-DD` (midnight UTC). `--after D` is inclusive
|
|
@@ -107,9 +107,9 @@ of day D; `--before D` is exclusive of day D (i.e. up to the start of that day).
|
|
|
107
107
|
|
|
108
108
|
## Agent tools
|
|
109
109
|
|
|
110
|
-
- **`episodic_search`** — `query` (+ optional `text`, `mode: vector|text|hybrid`, `after`, `before`, `limit`). `vector` (default) is semantic; `text` is lexical BM25; `hybrid` fuses both via RRF (opt-in — can surface lexical noise). Returns dated excerpts with session IDs, scores, and message anchors.
|
|
111
|
-
- **`episodic_read_window`** — `session_id`, `anchor_message_id` (+ optional `source_id`, required
|
|
112
|
-
- **`episodic_read_session`** — `session_id` (+ optional `source_id`, required
|
|
110
|
+
- **`episodic_search`** — `query` (+ optional `text`, `mode: vector|text|hybrid`, `after`, `before`, `limit`). `vector` (default) is semantic; `text` is lexical BM25; `hybrid` fuses both via RRF (opt-in — can surface lexical noise). Returns dated excerpts with session IDs, scores, and message anchors. Remote indexes are vector-only: the plugin exposes only `vector` there, and explicit remote `text`/`hybrid` requests fail before embedding with actionable guidance rather than silently falling back. Local text/hybrid behavior is unchanged.
|
|
111
|
+
- **`episodic_read_window`** — `session_id`, `anchor_message_id` (+ optional `source_id`, required for remote indexes; `before`, `after`, each 0-20, default 3). Current-source hits use privacy-gated live messages. Foreign-source hits use labeled indexed exchanges around the anchor, with before/after counting chunks instead of messages and each chunk rendered at up to 600 UTF-8 bytes. Missing or stale anchors cannot expand; use `episodic_read_session` with the same session/source and `indexed: true` for available indexed excerpts.
|
|
112
|
+
- **`episodic_read_session`** — `session_id` (+ optional `source_id`, required for remote indexes; `indexed`). Reads the full live transcript, or indexed excerpts when requested, deleted, or on another source. Current-source indexed reads still check the live privacy marker and withhold cached content if validation fails. Prefer `episodic_search` -> `episodic_read_window` -> `episodic_read_session`, stopping once enough context has been recovered.
|
|
113
113
|
|
|
114
114
|
## Excluding conversations
|
|
115
115
|
|
|
@@ -175,9 +175,13 @@ schema and does not use FTS, so remote mode supports vector search only. `--text
|
|
|
175
175
|
Remote cosine search reads embedding candidates in bounded pages and hydrates
|
|
176
176
|
only the final result set.
|
|
177
177
|
|
|
178
|
-
Remote search includes the source ID in each hit.
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
Remote search includes the source ID in each hit. `source_id` is required for
|
|
179
|
+
remote indexed reads. Another device's hit can be read through the bounded
|
|
180
|
+
indexed foreign-source path in `episodic_read_window` when its anchor is
|
|
181
|
+
present and current; otherwise use `episodic_read_session` with
|
|
182
|
+
`indexed: true`. These are condensed indexed excerpts, not a live transcript;
|
|
183
|
+
indexes remain stale until the source syncs and carry no live privacy or
|
|
184
|
+
freshness guarantees across devices.
|
|
181
185
|
|
|
182
186
|
Network failures are surfaced by the CLI and doctor; plugin background reindex
|
|
183
187
|
logs failures and never silently falls back to a local index (which would split
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-episodic-memory",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Semantic search over past OpenCode conversations — local embeddings (Transformers.js), SQLite index, native plugin tools. Inspired by obra/episodic-memory, rebuilt natively for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// - Native tools: episodic_search, episodic_read_window, episodic_read_session
|
|
3
3
|
// - Incremental reindex on session.idle (fire-and-forget, debounced)
|
|
4
4
|
import { type Plugin, tool } from "@opencode-ai/plugin";
|
|
5
|
-
import { openSource, getSession, getTranscriptChecked, getTranscriptContext } from "../src/reader";
|
|
5
|
+
import { openSource, getSession, getTranscriptChecked, getTranscriptContext, transcriptHasMarker } from "../src/reader";
|
|
6
6
|
import { canLiveRead, openConfiguredIndex, remoteIndexConfig, type IndexStore } from "../src/store";
|
|
7
7
|
import { syncSession, syncAll, pruneOrphans } from "../src/indexer";
|
|
8
8
|
import { embedQuery } from "../src/embed";
|
|
9
|
-
import { parseDateArg, formatHits, renderTranscript, renderTranscriptContext } from "../src/format";
|
|
9
|
+
import { parseDateArg, formatHits, renderTranscript, renderTranscriptContext, renderIndexedContext } from "../src/format";
|
|
10
10
|
|
|
11
11
|
export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
12
|
+
const remoteSearch = Boolean(process.env.EPISODIC_INDEX_URL);
|
|
12
13
|
const log = (level: "info" | "warn" | "error", message: string) =>
|
|
13
14
|
client.app
|
|
14
15
|
.log({ body: { service: "episodic-memory", level, message } })
|
|
@@ -67,16 +68,22 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
67
68
|
tool: {
|
|
68
69
|
episodic_search: tool({
|
|
69
70
|
description:
|
|
70
|
-
"Semantic search over your PAST OpenCode conversations. Use when the user references prior work, past decisions, or previous sessions (e.g. 'how did we handle X', 'the conversation about Y', 'what did we decide about Z'). Returns dated excerpts, session IDs, and anchors. Prefer episodic_search -> episodic_read_window for
|
|
71
|
+
"Semantic search over your PAST OpenCode conversations. Use when the user references prior work, past decisions, or previous sessions (e.g. 'how did we handle X', 'the conversation about Y', 'what did we decide about Z'). Returns dated excerpts, session IDs, and anchors. Prefer episodic_search -> episodic_read_window for bounded context -> episodic_read_session only when more context is needed." +
|
|
72
|
+
(remoteSearch ? " Remote index: vector search only, without text filtering. Preserve source_id when reading hits; foreign-source windows contain indexed excerpts, not live messages." : ""),
|
|
71
73
|
args: {
|
|
72
74
|
query: tool.schema.string().describe("Natural-language description of what you're looking for"),
|
|
73
|
-
text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)"),
|
|
74
|
-
mode: tool.schema.enum(["vector", "text", "hybrid"]).optional().describe(
|
|
75
|
+
...(!remoteSearch ? { text: tool.schema.string().optional().describe("Exact substring to require in results (ANDed with semantic ranking)") } : {}),
|
|
76
|
+
mode: tool.schema.enum(remoteSearch ? ["vector"] : ["vector", "text", "hybrid"]).optional().describe(remoteSearch
|
|
77
|
+
? "'vector' (default): the only supported remote mode. Scores are cosine (~0.4-0.7)."
|
|
78
|
+
: "'vector' (default) semantic search, scores are cosine (~0.4–0.7); 'text' lexical BM25; 'hybrid' fuses both via RRF (may surface lexical noise) — note hybrid hits carry fused RRF scores (~0.03), a DIFFERENT scale from cosine, so don't judge them against the vector thresholds"),
|
|
75
79
|
after: tool.schema.string().optional().describe("Only conversations after YYYY-MM-DD"),
|
|
76
80
|
before: tool.schema.string().optional().describe("Only conversations before YYYY-MM-DD"),
|
|
77
81
|
limit: tool.schema.number().optional().describe("Max results, 1-50 (default 10)"),
|
|
78
82
|
},
|
|
79
83
|
async execute(args) {
|
|
84
|
+
if (remoteIndexConfig() && (args.mode === "text" || args.mode === "hybrid" || args.text !== undefined)) {
|
|
85
|
+
return 'Remote indexes support vector search only, without text filtering. Retry with mode: "vector" and omit text; include relevant terms in query. Use a local index for exact text filtering, BM25, or hybrid search. No search was run.';
|
|
86
|
+
}
|
|
80
87
|
const index = await getIndex();
|
|
81
88
|
const after = parseDateArg(args.after);
|
|
82
89
|
if (!after.ok) return after.error;
|
|
@@ -86,7 +93,7 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
86
93
|
limit: Math.min(Math.max(args.limit ?? 10, 1), 50),
|
|
87
94
|
after: after.ms,
|
|
88
95
|
before: before.ms,
|
|
89
|
-
text: args.text,
|
|
96
|
+
text: typeof args.text === "string" ? args.text : undefined,
|
|
90
97
|
};
|
|
91
98
|
const noHits = async () => await index.isEmpty()
|
|
92
99
|
? "No matching past conversations found. The index is empty — run `bun run src/cli.ts sync` to index conversations."
|
|
@@ -116,18 +123,26 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
116
123
|
|
|
117
124
|
episodic_read_window: tool({
|
|
118
125
|
description:
|
|
119
|
-
"Read
|
|
126
|
+
"Read bounded context around an anchor from episodic_search. Preserve session_id, anchor_message_id, and source_id. Current-source hits use privacy-gated live messages; foreign-source hits use labeled indexed condensed exchanges that may be stale. Missing or stale anchors cannot provide a window; use episodic_read_session with source_id and indexed: true for indexed excerpts instead.",
|
|
120
127
|
args: {
|
|
121
128
|
session_id: tool.schema.string().describe("Session ID from episodic_search, e.g. ses_..."),
|
|
122
129
|
source_id: tool.schema.string().optional().describe("Source ID shown by remote episodic_search results; required for remote indexes"),
|
|
123
130
|
anchor_message_id: tool.schema.string().describe("Anchor message ID from episodic_search"),
|
|
124
|
-
before: tool.schema.number().optional().describe("Messages before the anchor, 0-20 (default 3)"),
|
|
125
|
-
after: tool.schema.number().optional().describe("Messages after the anchor, 0-20 (default 3)"),
|
|
131
|
+
before: tool.schema.number().optional().describe("Messages before the anchor, or indexed chunks for a foreign source, 0-20 (default 3)"),
|
|
132
|
+
after: tool.schema.number().optional().describe("Messages after the anchor, or indexed chunks for a foreign source, 0-20 (default 3)"),
|
|
126
133
|
},
|
|
127
134
|
async execute(args) {
|
|
128
135
|
const remote = remoteIndexConfig();
|
|
129
|
-
if (
|
|
130
|
-
throw new Error("
|
|
136
|
+
if (remote && !args.source_id) {
|
|
137
|
+
throw new Error("source_id is required for episodic_read_window with a remote index. Use the source from the search hit.");
|
|
138
|
+
}
|
|
139
|
+
if (args.source_id && !canLiveRead(remote, args.source_id)) {
|
|
140
|
+
const index = await getIndex();
|
|
141
|
+
const rows = await index.readIndexedWindow(args.session_id, args.anchor_message_id, args.before, args.after, args.source_id);
|
|
142
|
+
if (rows.length === 0) {
|
|
143
|
+
throw new Error('No indexed window found for this source, session, and anchor. The anchor may be stale; use episodic_read_session with the same session_id, source_id, and indexed: true for available indexed excerpts.');
|
|
144
|
+
}
|
|
145
|
+
return renderIndexedContext(args.session_id, args.source_id, args.anchor_message_id, rows);
|
|
131
146
|
}
|
|
132
147
|
const source = openSource();
|
|
133
148
|
const context = getTranscriptContext(source, args.session_id, args.anchor_message_id, args.before, args.after);
|
|
@@ -155,24 +170,30 @@ export const EpisodicMemory: Plugin = async ({ client }) => {
|
|
|
155
170
|
throw new Error("source_id is required for episodic_read_session with a remote index.");
|
|
156
171
|
}
|
|
157
172
|
const foreign = !canLiveRead(remote, args.source_id);
|
|
158
|
-
if (!
|
|
173
|
+
if (!foreign) {
|
|
159
174
|
try {
|
|
160
175
|
const source = openSource();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// raw-blob scan before any read).
|
|
165
|
-
const checked = getTranscriptChecked(source, args.session_id);
|
|
166
|
-
if (checked.excluded) {
|
|
176
|
+
try {
|
|
177
|
+
// Explicit indexed reads must also respect a marker added since sync.
|
|
178
|
+
if (transcriptHasMarker(source, args.session_id)) {
|
|
167
179
|
return "Session is marked private (exclusion marker present); transcript withheld.";
|
|
168
180
|
}
|
|
169
|
-
|
|
181
|
+
if (!args.indexed) {
|
|
182
|
+
const s = getSession(source, args.session_id);
|
|
183
|
+
if (s) {
|
|
184
|
+
const checked = getTranscriptChecked(source, args.session_id);
|
|
185
|
+
if (checked.excluded) {
|
|
186
|
+
return "Session is marked private (exclusion marker present); transcript withheld.";
|
|
187
|
+
}
|
|
188
|
+
return renderTranscript(s, checked.messages).slice(0, 50000);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
source.close();
|
|
170
193
|
}
|
|
171
194
|
} catch (e) {
|
|
172
|
-
// Log before falling through — a bare swallow would also hide
|
|
173
|
-
// structural Zod drift, which is meant to be loud.
|
|
174
195
|
await log("warn", `episodic_read_session live-store read failed for ${args.session_id}: ${e}`);
|
|
175
|
-
|
|
196
|
+
return "Live-source validation failed; indexed content withheld because current privacy status could not be verified.";
|
|
176
197
|
}
|
|
177
198
|
}
|
|
178
199
|
const index = await getIndex();
|
|
@@ -25,21 +25,27 @@ conversation — the index is for cross-session recall, not code search.
|
|
|
25
25
|
|
|
26
26
|
1. `episodic_search` with a natural-language query describing the *topic and intent*,
|
|
27
27
|
not exact keywords ("migrating from Claude Code to OpenCode", not "claude opencode").
|
|
28
|
-
- Narrow with `after`/`before` dates
|
|
29
|
-
(an error string, a flag name, a file path).
|
|
30
|
-
- `mode: "text"` for lexical BM25 search: every query word must appear (token-based
|
|
28
|
+
- Narrow with `after`/`before` dates. Local indexes also support an exact `text`
|
|
29
|
+
substring (an error string, a flag name, a file path).
|
|
30
|
+
- On local indexes, use `mode: "text"` for lexical BM25 search: every query word must appear (token-based
|
|
31
31
|
AND, BM25-ranked) — not phrase/adjacency or substring matching.
|
|
32
|
+
- Remote indexes support only vector search, without `text` filtering. Include
|
|
33
|
+
relevant terms in `query`; do not retry unsupported text/hybrid modes.
|
|
32
34
|
2. Skim the returned excerpts (date, session title, score, and anchor). Vector
|
|
33
35
|
similarity scores are NOT calibrated probabilities: ≥ ~0.55 is a strong
|
|
34
36
|
match, 0.4–0.55 is likely relevant, and < ~0.35 is weak or merely adjacent.
|
|
35
37
|
These thresholds apply only to vector results. For hybrid results, use the
|
|
36
38
|
snippet and the `rrf` label instead; RRF scores are on a different scale.
|
|
37
39
|
Say when the corpus doesn't really contain the topic.
|
|
38
|
-
3. `episodic_read_window` with the result's session ID
|
|
39
|
-
inspect a small
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
and
|
|
40
|
+
3. `episodic_read_window` with the result's session ID, anchor message ID, and
|
|
41
|
+
`source_id` (required for remote indexes) to inspect a small window first.
|
|
42
|
+
Current-source windows read privacy-gated live messages. Foreign-source windows
|
|
43
|
+
read labeled indexed exchanges: `before`/`after` count chunks, not messages,
|
|
44
|
+
and content may be stale until that source syncs. Missing or stale anchors
|
|
45
|
+
cannot expand. Stop on privacy denials rather than trying indexed reads.
|
|
46
|
+
4. `episodic_read_session` with the session ID and the same `source_id` when the
|
|
47
|
+
window is insufficient or unavailable. Use `indexed: true` for indexed excerpts,
|
|
48
|
+
including legacy unanchored hits. Do not present these as a full live transcript.
|
|
43
49
|
|
|
44
50
|
## Answering
|
|
45
51
|
|
package/src/format.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// parsing, date formatting, transcript→markdown, and search-hit formatting live
|
|
3
3
|
// here so the two front-ends can't drift apart.
|
|
4
4
|
import type { SourceMessage } from "./reader";
|
|
5
|
-
import type { SearchHit } from "./store";
|
|
5
|
+
import type { IndexedWindowRow, SearchHit } from "./store";
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
7
|
|
|
8
8
|
const MAX_CONTEXT_BODY_BYTES = 600;
|
|
@@ -86,6 +86,20 @@ export function renderTranscriptContext(
|
|
|
86
86
|
return lines.join("\n");
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export function renderIndexedContext(sessionId: string, sourceId: string, anchorMessageId: string, rows: IndexedWindowRow[]): string {
|
|
90
|
+
const lines = [
|
|
91
|
+
"# Indexed excerpts (not a live transcript)",
|
|
92
|
+
`source: ${truncateContext(sourceId, MAX_CONTEXT_FIELD_BYTES)} session: ${truncateContext(sessionId, MAX_CONTEXT_FIELD_BYTES)}`,
|
|
93
|
+
"Window bounds count condensed exchange chunks, not messages. Content may be stale until the source syncs.",
|
|
94
|
+
"",
|
|
95
|
+
];
|
|
96
|
+
for (const row of rows) {
|
|
97
|
+
lines.push(`## Chunk ${row.seq}${row.anchor_message_id === anchorMessageId ? " (anchor)" : ""}`);
|
|
98
|
+
lines.push(truncateContext(row.text, MAX_CONTEXT_BODY_BYTES), "");
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
function truncateContext(value: string, byteLimit: number): string {
|
|
90
104
|
if (Buffer.byteLength(value, "utf8") <= byteLimit) return value;
|
|
91
105
|
const suffix = "... [truncated]";
|
package/src/store.ts
CHANGED
|
@@ -442,9 +442,23 @@ export interface IndexStore {
|
|
|
442
442
|
isEmpty(): Promise<boolean>;
|
|
443
443
|
stats(): Promise<IndexStats>;
|
|
444
444
|
readIndexed(sessionId: string, sourceId?: string): Promise<{ text: string }[]>;
|
|
445
|
+
readIndexedWindow(sessionId: string, anchorMessageId: string, before?: number, after?: number, sourceId?: string): Promise<IndexedWindowRow[]>;
|
|
445
446
|
close(): void;
|
|
446
447
|
}
|
|
447
448
|
|
|
449
|
+
export interface IndexedWindowRow {
|
|
450
|
+
seq: number;
|
|
451
|
+
anchor_message_id: string | null;
|
|
452
|
+
text: string;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function indexedWindowBounds(before: number = 3, after: number = 3): { before: number; after: number } {
|
|
456
|
+
if (!Number.isInteger(before) || before < 0 || before > 20 || !Number.isInteger(after) || after < 0 || after > 20) {
|
|
457
|
+
throw new Error("before and after must be non-negative integers no greater than 20.");
|
|
458
|
+
}
|
|
459
|
+
return { before, after };
|
|
460
|
+
}
|
|
461
|
+
|
|
448
462
|
class LocalIndexStore implements IndexStore {
|
|
449
463
|
readonly remote = false;
|
|
450
464
|
constructor(private readonly db: Database) {}
|
|
@@ -479,6 +493,28 @@ class LocalIndexStore implements IndexStore {
|
|
|
479
493
|
async readIndexed(sessionId: string) {
|
|
480
494
|
return this.db.prepare<{ text: string }, [string]>("SELECT text FROM chunks WHERE session_id = ? ORDER BY seq").all(sessionId);
|
|
481
495
|
}
|
|
496
|
+
async readIndexedWindow(sessionId: string, anchorMessageId: string, before?: number, after?: number) {
|
|
497
|
+
const bounds = indexedWindowBounds(before, after);
|
|
498
|
+
return this.db.prepare<IndexedWindowRow, [string, string, string, number, string, number]>(
|
|
499
|
+
`WITH anchor AS (
|
|
500
|
+
SELECT seq FROM chunks WHERE session_id = ? AND anchor_message_id = ? ORDER BY seq LIMIT 1
|
|
501
|
+
), previous AS (
|
|
502
|
+
SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
|
|
503
|
+
FROM chunks c JOIN anchor a
|
|
504
|
+
WHERE c.session_id = ? AND c.seq < a.seq
|
|
505
|
+
ORDER BY c.seq DESC LIMIT ?
|
|
506
|
+
), following AS (
|
|
507
|
+
SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
|
|
508
|
+
FROM chunks c JOIN anchor a
|
|
509
|
+
WHERE c.session_id = ? AND c.seq >= a.seq
|
|
510
|
+
ORDER BY c.seq ASC LIMIT ?
|
|
511
|
+
)
|
|
512
|
+
SELECT seq, anchor_message_id, text FROM previous
|
|
513
|
+
UNION ALL
|
|
514
|
+
SELECT seq, anchor_message_id, text FROM following
|
|
515
|
+
ORDER BY seq`
|
|
516
|
+
).all(sessionId, anchorMessageId, sessionId, bounds.before, sessionId, bounds.after + 1);
|
|
517
|
+
}
|
|
482
518
|
close() { this.db.close(); }
|
|
483
519
|
}
|
|
484
520
|
|
|
@@ -763,6 +799,35 @@ class RemoteIndexStore implements IndexStore {
|
|
|
763
799
|
const result = await this.client.execute({ sql: "SELECT text FROM episodic_chunks WHERE source_id = ? AND session_id = ? ORDER BY seq", args: [sourceId, sessionId] });
|
|
764
800
|
return result.rows.map((row) => ({ text: rowString(row, "text") }));
|
|
765
801
|
}
|
|
802
|
+
async readIndexedWindow(sessionId: string, anchorMessageId: string, before?: number, after?: number, sourceId?: string): Promise<IndexedWindowRow[]> {
|
|
803
|
+
if (!sourceId) throw new Error("sourceId is required for remote indexed windows.");
|
|
804
|
+
const bounds = indexedWindowBounds(before, after);
|
|
805
|
+
const result = await this.client.execute({
|
|
806
|
+
sql: `WITH anchor AS (
|
|
807
|
+
SELECT seq FROM episodic_chunks
|
|
808
|
+
WHERE source_id = ? AND session_id = ? AND anchor_message_id = ?
|
|
809
|
+
ORDER BY seq LIMIT 1
|
|
810
|
+
), previous AS (
|
|
811
|
+
SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
|
|
812
|
+
FROM episodic_chunks c JOIN anchor a
|
|
813
|
+
WHERE c.source_id = ? AND c.session_id = ? AND c.seq < a.seq
|
|
814
|
+
ORDER BY c.seq DESC LIMIT ?
|
|
815
|
+
), following AS (
|
|
816
|
+
SELECT c.seq, c.anchor_message_id, substr(c.text, 1, 4000) AS text
|
|
817
|
+
FROM episodic_chunks c JOIN anchor a
|
|
818
|
+
WHERE c.source_id = ? AND c.session_id = ? AND c.seq >= a.seq
|
|
819
|
+
ORDER BY c.seq ASC LIMIT ?
|
|
820
|
+
)
|
|
821
|
+
SELECT seq, anchor_message_id, text FROM previous
|
|
822
|
+
UNION ALL
|
|
823
|
+
SELECT seq, anchor_message_id, text FROM following
|
|
824
|
+
ORDER BY seq`,
|
|
825
|
+
args: [sourceId, sessionId, anchorMessageId, sourceId, sessionId, bounds.before, sourceId, sessionId, bounds.after + 1],
|
|
826
|
+
});
|
|
827
|
+
return result.rows.map((row) => ({
|
|
828
|
+
seq: rowNumber(row, "seq"), anchor_message_id: rowNullableString(row, "anchor_message_id"), text: rowString(row, "text"),
|
|
829
|
+
}));
|
|
830
|
+
}
|
|
766
831
|
close() { this.client.close(); }
|
|
767
832
|
}
|
|
768
833
|
|