@ephemushroom/opencode-claude-mem 0.3.3 → 0.4.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/README.md CHANGED
@@ -39,9 +39,13 @@ into new OpenCode sessions automatically.
39
39
  compaction so long conversations keep their historical context.
40
40
  - **Observation Capture** — Sends tool observations to Claude-Mem for future
41
41
  retrieval and summarization.
42
- - **Native Memory Search** — Exposes a `mem-search` OpenCode tool backed by the
43
- Claude-Mem worker search API, so search remains available without the Claude
44
- Code compatibility bridge.
42
+ - **Native Memory Tools** — Exposes `mem-search`, `mem-timeline`, and
43
+ `mem-get-observations` OpenCode tools backed by the Claude-Mem worker API,
44
+ covering the full search → timeline → details workflow without the Claude
45
+ Code compatibility bridge or an MCP server.
46
+ - **Sidebar Status** — Registers a TUI sidebar panel ("Memory") showing worker
47
+ health, project, observation/summary/session counts, and processing queue
48
+ depth, refreshed every few seconds.
45
49
  - **Observation Hardening** — Skips low-value meta tools, strips
46
50
  `<claude-mem-context>` and `<private>` tags before storage, and truncates
47
51
  oversized observation payloads by UTF-8 byte size.
@@ -91,7 +95,9 @@ The worker endpoint is resolved in this order: `CLAUDE_MEM_WORKER_HOST` /
91
95
  | `SessionStart` | `experimental.session.compacting` | Preserve memory during compaction |
92
96
  | `UserPromptSubmit` | `chat.message` | Initialize session with real user prompt |
93
97
  | `PostToolUse` | `tool.execute.after` | Capture tool observations |
94
- | Claude-Mem MCP | `tool` (`mem-search`) | Search memory from OpenCode |
98
+ | Claude-Mem MCP `search` | `tool` (`mem-search`) | Search memory from OpenCode |
99
+ | Claude-Mem MCP `timeline` | `tool` (`mem-timeline`) | Chronological context around an observation |
100
+ | Claude-Mem MCP `get_observations` | `tool` (`mem-get-observations`) | Fetch full observation details by ID |
95
101
  | _(streaming)_ | `event` (`message.updated`) | Capture assistant text (debounced) |
96
102
  | _(streaming)_ | `event` (`file.edited`) | Record file edit observations |
97
103
  | _(compaction)_ | `event` (`session.compacted`) | Summarize after OpenCode compacts |
@@ -109,6 +115,9 @@ The worker endpoint is resolved in this order: `CLAUDE_MEM_WORKER_HOST` /
109
115
  | `POST` | `/api/sessions/summarize` | Trigger summarization |
110
116
  | `POST` | `/api/sessions/complete` | Complete session |
111
117
  | `GET` | `/api/search?query={query}&project={name}` | Search memory |
118
+ | `GET` | `/api/timeline?project={name}&anchor={id}` | Chronological context around an observation |
119
+ | `POST` | `/api/observations/batch` | Fetch full observation details by ID |
120
+ | `GET` | `/api/stats` + `/api/processing-status` | Sidebar status panel |
112
121
 
113
122
  Session and observation writes include `platformSource: "opencode"` so
114
123
  Claude-Mem can attribute new OpenCode memories separately from Claude Code,
@@ -181,23 +190,38 @@ Once installed, the plugin works automatically:
181
190
  accumulating stale `pending_messages` (the "queueDepth never decreases"
182
191
  failure mode).
183
192
 
184
- ## Memory Search
193
+ ## Memory Tools
185
194
 
186
- This plugin provides a native OpenCode tool named `mem-search`. It calls the
187
- Claude-Mem worker's `/api/search` endpoint for the current project and returns
188
- the same formatted result text the worker exposes over HTTP.
195
+ This plugin provides three native OpenCode tools backed by the Claude-Mem
196
+ worker's HTTP API. They cover the same search → timeline → details workflow as
197
+ the upstream Claude-Mem MCP server, without requiring an MCP server or the
198
+ Claude Code compatibility bridge:
199
+
200
+ - **`mem-search`** — `GET /api/search`. Returns a formatted index of matching
201
+ observations (with IDs) for the current project.
202
+ - **`mem-timeline`** — `GET /api/timeline`. Given an observation ID as `anchor`
203
+ (or a `query` to locate one), returns the chronological records around it.
204
+ - **`mem-get-observations`** — `POST /api/observations/batch`. Fetches full
205
+ observation details for a list of IDs (e.g. IDs surfaced in the injected
206
+ memory context or by `mem-search`).
189
207
 
190
208
  Claude Code's claude-mem plugin also registers an MCP server through its own
191
209
  `.mcp.json`. OpenCode does not automatically load Claude Code plugin MCP
192
210
  servers. If you previously saw Claude-Mem MCP tools in OpenCode, they were most
193
211
  likely supplied by a Claude Code compatibility bridge such as oh-my-openagent.
194
212
  Disabling that bridge for `claude-mem@thedotmack` removes those bridged MCP
195
- tools; `mem-search` is the native replacement inside this plugin.
213
+ tools; the three `mem-*` tools above are the native replacements inside this
214
+ plugin, so a separately configured MCP server is not required.
215
+
216
+ ## Sidebar Status
196
217
 
197
- The richer Claude-Mem MCP workflow (`search`, `timeline`, `get_observations`) can
198
- still be configured separately in OpenCode if you want it. It is not required for
199
- automatic context injection, observation capture, summarization, or the built-in
200
- `mem-search` tool.
218
+ When running in the OpenCode TUI, the plugin registers a sidebar panel titled
219
+ **Memory** via its `./tui` export. It polls `/api/stats` and
220
+ `/api/processing-status` every few seconds and shows worker health, the current
221
+ project, observation/summary/session counts, and the processing queue depth. The
222
+ panel fails open — if the worker is offline it simply shows `worker offline` and
223
+ never blocks the TUI. Requires OpenCode's `@opentui/solid` runtime; if that is
224
+ unavailable the panel is skipped silently.
201
225
 
202
226
  ## Key Implementation Details
203
227
 
@@ -281,12 +305,13 @@ plugin load. If that toast still appears:
281
305
  design.
282
306
  - Very large tool outputs are truncated before storage.
283
307
 
284
- ### Memory search is unavailable
308
+ ### Memory tools are unavailable
285
309
 
286
- - Use the built-in `mem-search` tool exposed by this plugin.
287
- - If you specifically need Claude-Mem's MCP `timeline` or `get_observations`
288
- tools, configure the Claude-Mem MCP server separately in OpenCode or enable a
289
- compatible bridge for MCP only.
310
+ - Use the built-in `mem-search`, `mem-timeline`, and `mem-get-observations`
311
+ tools exposed by this plugin — they cover the full Claude-Mem search workflow
312
+ natively, so a separate MCP server is not required.
313
+ - If the tools return `worker is offline`, confirm the worker health endpoint
314
+ responds (see above).
290
315
 
291
316
  ## Development
292
317
 
package/dist/index.js CHANGED
@@ -12574,6 +12574,79 @@ class WorkerClient {
12574
12574
  return null;
12575
12575
  }
12576
12576
  }
12577
+ static async timeline(options) {
12578
+ try {
12579
+ const params = new URLSearchParams({ project: options.project });
12580
+ if (options.anchor !== undefined) {
12581
+ params.set("anchor", String(options.anchor));
12582
+ }
12583
+ if (options.query) {
12584
+ params.set("query", options.query);
12585
+ }
12586
+ if (options.depthBefore !== undefined) {
12587
+ params.set("depth_before", String(options.depthBefore));
12588
+ }
12589
+ if (options.depthAfter !== undefined) {
12590
+ params.set("depth_after", String(options.depthAfter));
12591
+ }
12592
+ const response = await fetch(`${this.BASE_URL}/api/timeline?${params.toString()}`);
12593
+ if (!response.ok) {
12594
+ return "";
12595
+ }
12596
+ const contentType = response.headers.get("content-type") || "";
12597
+ if (contentType.includes("application/json")) {
12598
+ return extractTextContent(await response.json()) || "";
12599
+ }
12600
+ const text = await response.text();
12601
+ return text.trim();
12602
+ } catch {
12603
+ return "";
12604
+ }
12605
+ }
12606
+ static async getObservations(ids, project) {
12607
+ try {
12608
+ const body = { ids };
12609
+ if (project) {
12610
+ body["project"] = project;
12611
+ }
12612
+ const response = await fetch(`${this.BASE_URL}/api/observations/batch`, {
12613
+ method: "POST",
12614
+ headers: { "Content-Type": "application/json" },
12615
+ body: JSON.stringify(body)
12616
+ });
12617
+ if (!response.ok) {
12618
+ return "";
12619
+ }
12620
+ const contentType = response.headers.get("content-type") || "";
12621
+ if (contentType.includes("application/json")) {
12622
+ const payload = await response.json();
12623
+ if (Array.isArray(payload)) {
12624
+ return payload.length ? JSON.stringify(payload, null, 2) : "";
12625
+ }
12626
+ return extractTextContent(payload) || "";
12627
+ }
12628
+ const text = await response.text();
12629
+ return text.trim();
12630
+ } catch {
12631
+ return "";
12632
+ }
12633
+ }
12634
+ static async getStats() {
12635
+ try {
12636
+ const [statsRes, processingRes] = await Promise.all([
12637
+ fetch(`${this.BASE_URL}/api/stats`),
12638
+ fetch(`${this.BASE_URL}/api/processing-status`)
12639
+ ]);
12640
+ if (!statsRes.ok) {
12641
+ return null;
12642
+ }
12643
+ const stats = await statsRes.json();
12644
+ const processing = processingRes.ok ? await processingRes.json() : undefined;
12645
+ return { database: stats.database, processing };
12646
+ } catch {
12647
+ return null;
12648
+ }
12649
+ }
12577
12650
  static async search(query, project, limit) {
12578
12651
  try {
12579
12652
  const params = new URLSearchParams({ query, project });
@@ -12605,7 +12678,9 @@ var META_TOOLS = new Set([
12605
12678
  "getmcpresource",
12606
12679
  "listmcpresourcestool",
12607
12680
  "listmcptools",
12681
+ "mem-get-observations",
12608
12682
  "mem-search",
12683
+ "mem-timeline",
12609
12684
  "skill",
12610
12685
  "slashcommand",
12611
12686
  "todowrite"
@@ -12842,6 +12917,46 @@ var ClaudeMemPlugin = async (ctx) => {
12842
12917
  const result = await WorkerClient.search(query, projectName, limit);
12843
12918
  return result || `No Claude-Mem results found for "${query}".`;
12844
12919
  }
12920
+ }),
12921
+ "mem-timeline": tool({
12922
+ description: "Get chronological Claude-Mem context around an observation. Use after mem-search: pass an observation ID as anchor (or a query to find the anchor automatically) to see what happened before/after.",
12923
+ args: {
12924
+ anchor: tool.schema.number().int().positive().optional().describe("Observation ID to center the timeline around"),
12925
+ query: tool.schema.string().min(1).optional().describe("Query to locate the anchor automatically (used when anchor is omitted)"),
12926
+ depth_before: tool.schema.number().int().min(0).max(20).optional().describe("Items before the anchor (default 3)"),
12927
+ depth_after: tool.schema.number().int().min(0).max(20).optional().describe("Items after the anchor (default 3)")
12928
+ },
12929
+ execute: async ({ anchor, query, depth_before, depth_after }) => {
12930
+ if (anchor === undefined && !query) {
12931
+ return "Provide either an anchor observation ID or a query.";
12932
+ }
12933
+ const isHealthy = await checkWorkerAndToast();
12934
+ if (!isHealthy) {
12935
+ return "Claude-Mem worker is offline. Start Claude-Mem and retry.";
12936
+ }
12937
+ const result = await WorkerClient.timeline({
12938
+ project: projectName,
12939
+ anchor,
12940
+ query,
12941
+ depthBefore: depth_before,
12942
+ depthAfter: depth_after
12943
+ });
12944
+ return result || "No Claude-Mem timeline results found.";
12945
+ }
12946
+ }),
12947
+ "mem-get-observations": tool({
12948
+ description: "Fetch full Claude-Mem observation details by ID. Use for IDs shown in the injected memory context or returned by mem-search/mem-timeline.",
12949
+ args: {
12950
+ ids: tool.schema.array(tool.schema.number().int().positive()).min(1).max(50).describe("Observation IDs to fetch")
12951
+ },
12952
+ execute: async ({ ids }) => {
12953
+ const isHealthy = await checkWorkerAndToast();
12954
+ if (!isHealthy) {
12955
+ return "Claude-Mem worker is offline. Start Claude-Mem and retry.";
12956
+ }
12957
+ const result = await WorkerClient.getObservations(ids, projectName);
12958
+ return result || `No Claude-Mem observations found for IDs [${ids.join(", ")}].`;
12959
+ }
12845
12960
  })
12846
12961
  },
12847
12962
  event: async ({ event }) => {
package/dist/tui.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ export interface MemSidebarView {
2
+ readonly healthy: boolean;
3
+ readonly project: string;
4
+ readonly observations: number | null;
5
+ readonly sessions: number | null;
6
+ readonly summaries: number | null;
7
+ readonly queueDepth: number | null;
8
+ readonly isProcessing: boolean;
9
+ }
10
+ interface ViewNode {
11
+ readonly kind: 'box' | 'text';
12
+ readonly props: Record<string, unknown>;
13
+ readonly children?: readonly ViewNode[];
14
+ readonly text?: string;
15
+ }
16
+ interface Theme {
17
+ readonly text: string;
18
+ readonly textMuted: string;
19
+ readonly info: string;
20
+ readonly success: string;
21
+ readonly warning: string;
22
+ readonly error: string;
23
+ readonly borderSubtle: string;
24
+ }
25
+ export declare function buildMemNodes(view: MemSidebarView, theme: Theme): ViewNode[];
26
+ export declare function viewKey(view: MemSidebarView): string;
27
+ export declare function readMemView(project: string): Promise<MemSidebarView>;
28
+ declare const tuiModule: {
29
+ id: string;
30
+ tui: (api: any) => Promise<void>;
31
+ };
32
+ export default tuiModule;
package/dist/tui.js ADDED
@@ -0,0 +1,488 @@
1
+ // @bun
2
+ var __require = import.meta.require;
3
+
4
+ // src/worker-client.ts
5
+ import { existsSync, readFileSync } from "fs";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ import { spawn } from "child_process";
9
+ var HEALTH_TIMEOUT_MS = 1000;
10
+ var AUTOSTART_POLL_INTERVAL_MS = 500;
11
+ var AUTOSTART_POLL_MAX_ATTEMPTS = 16;
12
+ var DEFAULT_WORKER_HOST = "127.0.0.1";
13
+ var DEFAULT_WORKER_PORT = 37777;
14
+ var PLATFORM_SOURCE = "opencode";
15
+ var SETTINGS_FILE = join(homedir(), ".claude-mem", "settings.json");
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function readString(value) {
20
+ if (typeof value !== "string") {
21
+ return null;
22
+ }
23
+ const trimmed = value.trim();
24
+ return trimmed || null;
25
+ }
26
+ function readPort(value) {
27
+ const text = readString(value);
28
+ if (!text || !/^\d+$/.test(text)) {
29
+ return null;
30
+ }
31
+ const port = Number(text);
32
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
33
+ }
34
+ function parseSettings(rawSettings) {
35
+ if (!rawSettings) {
36
+ return {};
37
+ }
38
+ try {
39
+ const parsed = JSON.parse(rawSettings);
40
+ return isRecord(parsed) ? parsed : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+ function parseWorkerEndpoint(env, rawSettings) {
46
+ const settings = parseSettings(rawSettings);
47
+ const host = readString(env["CLAUDE_MEM_WORKER_HOST"]) ?? readString(settings["CLAUDE_MEM_WORKER_HOST"]) ?? DEFAULT_WORKER_HOST;
48
+ const port = readPort(env["CLAUDE_MEM_WORKER_PORT"]) ?? readPort(settings["CLAUDE_MEM_WORKER_PORT"]) ?? DEFAULT_WORKER_PORT;
49
+ return { host, port };
50
+ }
51
+ function readSettingsRaw() {
52
+ try {
53
+ return readFileSync(SETTINGS_FILE, "utf8");
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+ function getWorkerBaseUrl() {
59
+ const endpoint = parseWorkerEndpoint(process.env, readSettingsRaw());
60
+ return `http://${endpoint.host}:${endpoint.port}`;
61
+ }
62
+ function extractTextContent(value) {
63
+ if (typeof value === "string") {
64
+ return value.trim() || null;
65
+ }
66
+ if (!isRecord(value)) {
67
+ return null;
68
+ }
69
+ const { content } = value;
70
+ if (typeof content === "string") {
71
+ return content.trim() || null;
72
+ }
73
+ if (Array.isArray(content)) {
74
+ const text2 = content.map((part) => {
75
+ if (!isRecord(part) || part["type"] !== "text" || typeof part["text"] !== "string") {
76
+ return "";
77
+ }
78
+ return part["text"];
79
+ }).filter(Boolean).join(`
80
+ `).trim();
81
+ return text2 || null;
82
+ }
83
+ const text = JSON.stringify(value, null, 2);
84
+ return text === "{}" || text === "null" ? null : text;
85
+ }
86
+
87
+ class WorkerClient {
88
+ static BASE_URL = getWorkerBaseUrl();
89
+ static autoStartPromise = null;
90
+ static async isHealthy() {
91
+ try {
92
+ const controller = new AbortController;
93
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
94
+ const response = await fetch(`${this.BASE_URL}/api/health`, {
95
+ signal: controller.signal
96
+ });
97
+ clearTimeout(timeoutId);
98
+ return response.ok;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ static async ensureRunning() {
104
+ const result = await this.tryAutoStart();
105
+ return result === "already-running" || result === "started";
106
+ }
107
+ static tryAutoStart() {
108
+ if (!this.autoStartPromise) {
109
+ this.autoStartPromise = this.runAutoStart().catch(() => "spawn-failed");
110
+ }
111
+ return this.autoStartPromise;
112
+ }
113
+ static async runAutoStart() {
114
+ if (await this.isHealthy()) {
115
+ return "already-running";
116
+ }
117
+ const bunPath = this.findBun();
118
+ if (!bunPath) {
119
+ return "no-bun";
120
+ }
121
+ try {
122
+ const child = spawn(bunPath, ["x", "claude-mem", "start"], {
123
+ detached: true,
124
+ stdio: "ignore",
125
+ windowsHide: true,
126
+ env: process.env
127
+ });
128
+ child.on("error", () => {});
129
+ child.unref();
130
+ } catch {
131
+ return "spawn-failed";
132
+ }
133
+ for (let i = 0;i < AUTOSTART_POLL_MAX_ATTEMPTS; i++) {
134
+ await new Promise((resolve) => setTimeout(resolve, AUTOSTART_POLL_INTERVAL_MS));
135
+ if (await this.isHealthy()) {
136
+ return "started";
137
+ }
138
+ }
139
+ return "timeout";
140
+ }
141
+ static findBun() {
142
+ const bunGlobal = globalThis.Bun;
143
+ if (bunGlobal && typeof bunGlobal.which === "function") {
144
+ try {
145
+ const found = bunGlobal.which("bun");
146
+ if (typeof found === "string" && found) {
147
+ return found;
148
+ }
149
+ } catch {}
150
+ }
151
+ const path = process.env.PATH || "";
152
+ const sep = process.platform === "win32" ? ";" : ":";
153
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
154
+ const slash = process.platform === "win32" ? "\\" : "/";
155
+ const dirs = path.split(sep).filter(Boolean);
156
+ for (const dir of dirs) {
157
+ const trailing = dir.endsWith("/") || dir.endsWith("\\") ? "" : slash;
158
+ for (const ext of exts) {
159
+ const candidate = `${dir}${trailing}bun${ext}`;
160
+ try {
161
+ if (existsSync(candidate)) {
162
+ return candidate;
163
+ }
164
+ } catch {}
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+ static async sessionInit(contentSessionId, project, prompt) {
170
+ try {
171
+ const response = await fetch(`${this.BASE_URL}/api/sessions/init`, {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({
175
+ contentSessionId,
176
+ project,
177
+ prompt,
178
+ platformSource: PLATFORM_SOURCE
179
+ })
180
+ });
181
+ if (!response.ok) {
182
+ return null;
183
+ }
184
+ return await response.json();
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+ static async sendObservation(contentSessionId, toolName, toolInput, toolResponse, cwd) {
190
+ try {
191
+ await fetch(`${this.BASE_URL}/api/sessions/observations`, {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify({
195
+ contentSessionId,
196
+ tool_name: toolName,
197
+ tool_input: toolInput,
198
+ tool_response: toolResponse,
199
+ cwd,
200
+ platformSource: PLATFORM_SOURCE
201
+ })
202
+ });
203
+ } catch {}
204
+ }
205
+ static async summarize(contentSessionId, lastUserMessage, lastAssistantMessage) {
206
+ try {
207
+ await fetch(`${this.BASE_URL}/api/sessions/summarize`, {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/json" },
210
+ body: JSON.stringify({
211
+ contentSessionId,
212
+ last_user_message: lastUserMessage,
213
+ last_assistant_message: lastAssistantMessage,
214
+ platformSource: PLATFORM_SOURCE
215
+ })
216
+ });
217
+ } catch {}
218
+ }
219
+ static async completeSession(contentSessionId) {
220
+ try {
221
+ await fetch(`${this.BASE_URL}/api/sessions/complete`, {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json" },
224
+ body: JSON.stringify({ contentSessionId, platformSource: PLATFORM_SOURCE })
225
+ });
226
+ } catch {}
227
+ }
228
+ static async getContext(project) {
229
+ try {
230
+ const response = await fetch(`${this.BASE_URL}/api/context/inject?project=${encodeURIComponent(project)}`);
231
+ if (!response.ok) {
232
+ return null;
233
+ }
234
+ const contentType = response.headers.get("content-type") || "";
235
+ if (contentType.includes("application/json")) {
236
+ return extractTextContent(await response.json());
237
+ }
238
+ const text = await response.text();
239
+ return text.trim() || null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+ static async timeline(options) {
245
+ try {
246
+ const params = new URLSearchParams({ project: options.project });
247
+ if (options.anchor !== undefined) {
248
+ params.set("anchor", String(options.anchor));
249
+ }
250
+ if (options.query) {
251
+ params.set("query", options.query);
252
+ }
253
+ if (options.depthBefore !== undefined) {
254
+ params.set("depth_before", String(options.depthBefore));
255
+ }
256
+ if (options.depthAfter !== undefined) {
257
+ params.set("depth_after", String(options.depthAfter));
258
+ }
259
+ const response = await fetch(`${this.BASE_URL}/api/timeline?${params.toString()}`);
260
+ if (!response.ok) {
261
+ return "";
262
+ }
263
+ const contentType = response.headers.get("content-type") || "";
264
+ if (contentType.includes("application/json")) {
265
+ return extractTextContent(await response.json()) || "";
266
+ }
267
+ const text = await response.text();
268
+ return text.trim();
269
+ } catch {
270
+ return "";
271
+ }
272
+ }
273
+ static async getObservations(ids, project) {
274
+ try {
275
+ const body = { ids };
276
+ if (project) {
277
+ body["project"] = project;
278
+ }
279
+ const response = await fetch(`${this.BASE_URL}/api/observations/batch`, {
280
+ method: "POST",
281
+ headers: { "Content-Type": "application/json" },
282
+ body: JSON.stringify(body)
283
+ });
284
+ if (!response.ok) {
285
+ return "";
286
+ }
287
+ const contentType = response.headers.get("content-type") || "";
288
+ if (contentType.includes("application/json")) {
289
+ const payload = await response.json();
290
+ if (Array.isArray(payload)) {
291
+ return payload.length ? JSON.stringify(payload, null, 2) : "";
292
+ }
293
+ return extractTextContent(payload) || "";
294
+ }
295
+ const text = await response.text();
296
+ return text.trim();
297
+ } catch {
298
+ return "";
299
+ }
300
+ }
301
+ static async getStats() {
302
+ try {
303
+ const [statsRes, processingRes] = await Promise.all([
304
+ fetch(`${this.BASE_URL}/api/stats`),
305
+ fetch(`${this.BASE_URL}/api/processing-status`)
306
+ ]);
307
+ if (!statsRes.ok) {
308
+ return null;
309
+ }
310
+ const stats = await statsRes.json();
311
+ const processing = processingRes.ok ? await processingRes.json() : undefined;
312
+ return { database: stats.database, processing };
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+ static async search(query, project, limit) {
318
+ try {
319
+ const params = new URLSearchParams({ query, project });
320
+ if (limit !== undefined) {
321
+ params.set("limit", String(limit));
322
+ }
323
+ const response = await fetch(`${this.BASE_URL}/api/search?${params.toString()}`);
324
+ if (!response.ok) {
325
+ return "";
326
+ }
327
+ const contentType = response.headers.get("content-type") || "";
328
+ if (contentType.includes("application/json")) {
329
+ return extractTextContent(await response.json()) || "";
330
+ }
331
+ const text = await response.text();
332
+ return text.trim();
333
+ } catch {
334
+ return "";
335
+ }
336
+ }
337
+ }
338
+
339
+ // src/tui.ts
340
+ var POLL_INTERVAL_MS = 5000;
341
+ var LABEL_MAX = 28;
342
+ function box(props, children) {
343
+ return { kind: "box", props, children };
344
+ }
345
+ function text(props, value) {
346
+ return { kind: "text", props, text: value };
347
+ }
348
+ function truncate(value) {
349
+ return value.length <= LABEL_MAX ? value : `${value.slice(0, LABEL_MAX - 3)}...`;
350
+ }
351
+ function formatCount(value) {
352
+ if (value === null) {
353
+ return "-";
354
+ }
355
+ return value >= 1e4 ? `${(value / 1000).toFixed(1)}k` : String(value);
356
+ }
357
+ function buildMemNodes(view, theme) {
358
+ const rows = view.healthy ? [
359
+ text({ fg: theme.success }, "worker online"),
360
+ text({ fg: theme.text }, `project ${truncate(view.project)}`),
361
+ text({ fg: theme.text }, `obs ${formatCount(view.observations)} \xB7 sum ${formatCount(view.summaries)}`),
362
+ text({ fg: theme.textMuted }, `sessions ${formatCount(view.sessions)}`),
363
+ ...view.isProcessing || (view.queueDepth ?? 0) > 0 ? [text({ fg: theme.warning }, `processing \xB7 queue ${view.queueDepth ?? 0}`)] : []
364
+ ] : [text({ fg: theme.error }, "worker offline")];
365
+ return [
366
+ box({
367
+ borderStyle: "single",
368
+ borderColor: theme.borderSubtle,
369
+ flexDirection: "column",
370
+ padding: 1
371
+ }, [text({ fg: theme.info }, "Memory"), ...rows])
372
+ ];
373
+ }
374
+ function viewKey(view) {
375
+ return JSON.stringify(view);
376
+ }
377
+ function readCount(value) {
378
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
379
+ }
380
+ async function readMemView(project) {
381
+ const stats = await WorkerClient.getStats();
382
+ if (!stats) {
383
+ return {
384
+ healthy: false,
385
+ project,
386
+ observations: null,
387
+ sessions: null,
388
+ summaries: null,
389
+ queueDepth: null,
390
+ isProcessing: false
391
+ };
392
+ }
393
+ return {
394
+ healthy: true,
395
+ project,
396
+ observations: readCount(stats.database?.observations),
397
+ sessions: readCount(stats.database?.sessions),
398
+ summaries: readCount(stats.database?.summaries),
399
+ queueDepth: readCount(stats.processing?.queueDepth),
400
+ isProcessing: stats.processing?.isProcessing === true
401
+ };
402
+ }
403
+ function materializeNode(node, solid) {
404
+ const element = solid.createElement(node.kind);
405
+ for (const [name, value] of Object.entries(node.props)) {
406
+ solid.setProp(element, name, value);
407
+ }
408
+ if (node.kind === "text") {
409
+ solid.insert(element, node.text ?? "");
410
+ }
411
+ for (const child of node.children ?? []) {
412
+ solid.insert(element, materializeNode(child, solid));
413
+ }
414
+ return element;
415
+ }
416
+ function materialize(nodes, solid) {
417
+ const root = solid.createElement("box");
418
+ solid.setProp(root, "flexDirection", "column");
419
+ for (const node of nodes) {
420
+ solid.insert(root, materializeNode(node, solid));
421
+ }
422
+ return root;
423
+ }
424
+ function projectNameFromDirectory(directory) {
425
+ return directory.split(/[\\/]/).findLast(Boolean) || "unknown-project";
426
+ }
427
+ var tuiModule = {
428
+ id: "opencode-claude-mem:tui",
429
+ tui: async (api) => {
430
+ const solid = await import("@opentui/solid").catch(() => null);
431
+ if (!solid) {
432
+ return;
433
+ }
434
+ const project = projectNameFromDirectory(String(api.state?.path?.directory ?? ""));
435
+ let currentView = await readMemView(project);
436
+ let currentKey = viewKey(currentView);
437
+ let disposed = false;
438
+ let inFlight = false;
439
+ let timer = null;
440
+ api.slots.register({
441
+ order: 910,
442
+ slots: {
443
+ sidebar_content: () => materialize(buildMemNodes(currentView, api.theme.current), solid)
444
+ }
445
+ });
446
+ api.renderer.requestRender();
447
+ const schedule = () => {
448
+ timer = setTimeout(tick, POLL_INTERVAL_MS);
449
+ };
450
+ const tick = async () => {
451
+ if (disposed || inFlight) {
452
+ if (!disposed) {
453
+ schedule();
454
+ }
455
+ return;
456
+ }
457
+ inFlight = true;
458
+ try {
459
+ const nextView = await readMemView(project);
460
+ const nextKey = viewKey(nextView);
461
+ if (nextKey !== currentKey) {
462
+ currentView = nextView;
463
+ currentKey = nextKey;
464
+ api.renderer.requestRender();
465
+ }
466
+ } catch {} finally {
467
+ inFlight = false;
468
+ if (!disposed) {
469
+ schedule();
470
+ }
471
+ }
472
+ };
473
+ schedule();
474
+ api.lifecycle.onDispose(() => {
475
+ disposed = true;
476
+ if (timer) {
477
+ clearTimeout(timer);
478
+ }
479
+ });
480
+ }
481
+ };
482
+ var tui_default = tuiModule;
483
+ export {
484
+ viewKey,
485
+ readMemView,
486
+ tui_default as default,
487
+ buildMemNodes
488
+ };
@@ -64,5 +64,36 @@ export declare class WorkerClient {
64
64
  * Uses /api/context/inject which returns rich markdown with observations + session summaries.
65
65
  */
66
66
  static getContext(project: string): Promise<string | null>;
67
+ /**
68
+ * Timeline: get chronological context around an observation anchor or query.
69
+ * Mirrors the upstream MCP `timeline` tool (GET /api/timeline).
70
+ */
71
+ static timeline(options: {
72
+ project: string;
73
+ anchor?: number;
74
+ query?: string;
75
+ depthBefore?: number;
76
+ depthAfter?: number;
77
+ }): Promise<string>;
78
+ /**
79
+ * Fetch full observation details by IDs.
80
+ * Mirrors the upstream MCP `get_observations` tool (POST /api/observations/batch).
81
+ */
82
+ static getObservations(ids: number[], project?: string): Promise<string>;
83
+ /**
84
+ * Fetch worker + database stats plus processing status for the sidebar view.
85
+ * Combines GET /api/stats and GET /api/processing-status; null when offline.
86
+ */
87
+ static getStats(): Promise<{
88
+ database?: {
89
+ observations?: number;
90
+ sessions?: number;
91
+ summaries?: number;
92
+ };
93
+ processing?: {
94
+ isProcessing?: boolean;
95
+ queueDepth?: number;
96
+ };
97
+ } | null>;
67
98
  static search(query: string, project: string, limit?: number): Promise<string>;
68
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ephemushroom/opencode-claude-mem",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "OpenCode plugin for Claude-Mem persistent memory system",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -16,10 +16,14 @@
16
16
  "./server": {
17
17
  "types": "./dist/index.d.ts",
18
18
  "import": "./dist/index.js"
19
+ },
20
+ "./tui": {
21
+ "types": "./dist/tui.d.ts",
22
+ "import": "./dist/tui.js"
19
23
  }
20
24
  },
21
25
  "scripts": {
22
- "build": "bun build src/index.ts --outfile dist/index.js --target bun --format esm && tsc -p tsconfig.build.json --emitDeclarationOnly",
26
+ "build": "bun build src/index.ts --outfile dist/index.js --target bun --format esm && bun build src/tui.ts --outfile dist/tui.js --target bun --format esm --external @opentui/solid && tsc -p tsconfig.build.json --emitDeclarationOnly",
23
27
  "dev": "tsc --watch",
24
28
  "clean": "rm -rf dist",
25
29
  "lint": "oxlint",