@ephemushroom/opencode-claude-mem 0.3.3 → 0.4.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 +60 -19
- package/dist/index.js +229 -0
- package/dist/tui-registration.d.ts +9 -0
- package/dist/tui.d.ts +41 -0
- package/dist/tui.js +616 -0
- package/dist/worker-client.d.ts +46 -0
- package/package.json +6 -2
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
|
|
43
|
-
|
|
44
|
-
|
|
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,10 @@ 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 |
|
|
121
|
+
| `GET` | `/api/summaries` + `/api/observations` | Sidebar recent items (expanded only) |
|
|
112
122
|
|
|
113
123
|
Session and observation writes include `platformSource: "opencode"` so
|
|
114
124
|
Claude-Mem can attribute new OpenCode memories separately from Claude Code,
|
|
@@ -181,23 +191,53 @@ Once installed, the plugin works automatically:
|
|
|
181
191
|
accumulating stale `pending_messages` (the "queueDepth never decreases"
|
|
182
192
|
failure mode).
|
|
183
193
|
|
|
184
|
-
## Memory
|
|
194
|
+
## Memory Tools
|
|
185
195
|
|
|
186
|
-
This plugin provides
|
|
187
|
-
|
|
188
|
-
the
|
|
196
|
+
This plugin provides three native OpenCode tools backed by the Claude-Mem
|
|
197
|
+
worker's HTTP API. They cover the same search → timeline → details workflow as
|
|
198
|
+
the upstream Claude-Mem MCP server, without requiring an MCP server or the
|
|
199
|
+
Claude Code compatibility bridge:
|
|
200
|
+
|
|
201
|
+
- **`mem-search`** — `GET /api/search`. Returns a formatted index of matching
|
|
202
|
+
observations (with IDs) for the current project.
|
|
203
|
+
- **`mem-timeline`** — `GET /api/timeline`. Given an observation ID as `anchor`
|
|
204
|
+
(or a `query` to locate one), returns the chronological records around it.
|
|
205
|
+
- **`mem-get-observations`** — `POST /api/observations/batch`. Fetches full
|
|
206
|
+
observation details for a list of IDs (e.g. IDs surfaced in the injected
|
|
207
|
+
memory context or by `mem-search`).
|
|
189
208
|
|
|
190
209
|
Claude Code's claude-mem plugin also registers an MCP server through its own
|
|
191
210
|
`.mcp.json`. OpenCode does not automatically load Claude Code plugin MCP
|
|
192
211
|
servers. If you previously saw Claude-Mem MCP tools in OpenCode, they were most
|
|
193
212
|
likely supplied by a Claude Code compatibility bridge such as oh-my-openagent.
|
|
194
213
|
Disabling that bridge for `claude-mem@thedotmack` removes those bridged MCP
|
|
195
|
-
tools; `mem
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
214
|
+
tools; the three `mem-*` tools above are the native replacements inside this
|
|
215
|
+
plugin, so a separately configured MCP server is not required.
|
|
216
|
+
|
|
217
|
+
## Sidebar Status
|
|
218
|
+
|
|
219
|
+
When running in the OpenCode TUI, the plugin registers a sidebar section titled
|
|
220
|
+
**Memory** via its `./tui` export, styled after OpenCode's native MCP/Context
|
|
221
|
+
sections: a borderless, click-to-toggle collapsible block.
|
|
222
|
+
|
|
223
|
+
- **Collapsed (default)** — a single line: `▶ Memory (online, 11.7k obs)`.
|
|
224
|
+
The summary turns yellow when the worker is processing (`(queue N)`) and red
|
|
225
|
+
when it is offline (`(offline)`).
|
|
226
|
+
- **Expanded** — click the header to expand: database counts, processing
|
|
227
|
+
status, the last 3 session summaries (`Recent sessions`), and the last 3
|
|
228
|
+
observations (`Latest`, with the same type icons used in the injected
|
|
229
|
+
context: ◆ feature, ● bugfix, ⚖ decision, ○ discovery, …).
|
|
230
|
+
|
|
231
|
+
Recent items are only fetched while expanded, so the collapsed poll loop stays
|
|
232
|
+
cheap. The panel fails open — if the worker is offline it simply shows the
|
|
233
|
+
offline state and never blocks the TUI. Requires OpenCode's `@opentui/solid`
|
|
234
|
+
runtime; if that is unavailable the panel is skipped silently.
|
|
235
|
+
|
|
236
|
+
The plugin also self-heals `~/.config/opencode/tui.json` on load (like
|
|
237
|
+
oh-my-openagent does): if the plugin is registered as a server plugin in
|
|
238
|
+
`opencode.json` but missing from the TUI plugin list, it appends itself so the
|
|
239
|
+
sidebar loads without manual configuration. Symlinked `tui.json` files are
|
|
240
|
+
written through, preserving dotfiles setups.
|
|
201
241
|
|
|
202
242
|
## Key Implementation Details
|
|
203
243
|
|
|
@@ -281,12 +321,13 @@ plugin load. If that toast still appears:
|
|
|
281
321
|
design.
|
|
282
322
|
- Very large tool outputs are truncated before storage.
|
|
283
323
|
|
|
284
|
-
### Memory
|
|
324
|
+
### Memory tools are unavailable
|
|
285
325
|
|
|
286
|
-
- Use the built-in `mem-search`
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
326
|
+
- Use the built-in `mem-search`, `mem-timeline`, and `mem-get-observations`
|
|
327
|
+
tools exposed by this plugin — they cover the full Claude-Mem search workflow
|
|
328
|
+
natively, so a separate MCP server is not required.
|
|
329
|
+
- If the tools return `worker is offline`, confirm the worker health endpoint
|
|
330
|
+
responds (see above).
|
|
290
331
|
|
|
291
332
|
## Development
|
|
292
333
|
|
package/dist/index.js
CHANGED
|
@@ -12574,6 +12574,124 @@ 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
|
+
}
|
|
12650
|
+
static async getRecentSummaries(project, limit) {
|
|
12651
|
+
try {
|
|
12652
|
+
const params = new URLSearchParams({ project, limit: String(limit) });
|
|
12653
|
+
const response = await fetch(`${this.BASE_URL}/api/summaries?${params.toString()}`);
|
|
12654
|
+
if (!response.ok) {
|
|
12655
|
+
return [];
|
|
12656
|
+
}
|
|
12657
|
+
const payload = await response.json();
|
|
12658
|
+
if (!Array.isArray(payload.items)) {
|
|
12659
|
+
return [];
|
|
12660
|
+
}
|
|
12661
|
+
return payload.items.flatMap((item) => {
|
|
12662
|
+
if (!isRecord(item) || typeof item["id"] !== "number") {
|
|
12663
|
+
return [];
|
|
12664
|
+
}
|
|
12665
|
+
const request = typeof item["request"] === "string" ? item["request"].trim() : "";
|
|
12666
|
+
return request ? [{ id: item["id"], request }] : [];
|
|
12667
|
+
});
|
|
12668
|
+
} catch {
|
|
12669
|
+
return [];
|
|
12670
|
+
}
|
|
12671
|
+
}
|
|
12672
|
+
static async getRecentObservations(project, limit) {
|
|
12673
|
+
try {
|
|
12674
|
+
const params = new URLSearchParams({ project, limit: String(limit) });
|
|
12675
|
+
const response = await fetch(`${this.BASE_URL}/api/observations?${params.toString()}`);
|
|
12676
|
+
if (!response.ok) {
|
|
12677
|
+
return [];
|
|
12678
|
+
}
|
|
12679
|
+
const payload = await response.json();
|
|
12680
|
+
if (!Array.isArray(payload.items)) {
|
|
12681
|
+
return [];
|
|
12682
|
+
}
|
|
12683
|
+
return payload.items.flatMap((item) => {
|
|
12684
|
+
if (!isRecord(item) || typeof item["id"] !== "number") {
|
|
12685
|
+
return [];
|
|
12686
|
+
}
|
|
12687
|
+
const title = typeof item["title"] === "string" ? item["title"].trim() : "";
|
|
12688
|
+
const type = typeof item["type"] === "string" ? item["type"] : "";
|
|
12689
|
+
return title ? [{ id: item["id"], type, title }] : [];
|
|
12690
|
+
});
|
|
12691
|
+
} catch {
|
|
12692
|
+
return [];
|
|
12693
|
+
}
|
|
12694
|
+
}
|
|
12577
12695
|
static async search(query, project, limit) {
|
|
12578
12696
|
try {
|
|
12579
12697
|
const params = new URLSearchParams({ query, project });
|
|
@@ -12596,6 +12714,74 @@ class WorkerClient {
|
|
|
12596
12714
|
}
|
|
12597
12715
|
}
|
|
12598
12716
|
|
|
12717
|
+
// src/tui-registration.ts
|
|
12718
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
12719
|
+
import { homedir as homedir2 } from "os";
|
|
12720
|
+
import { join as join2 } from "path";
|
|
12721
|
+
var PACKAGE_NAME = "@ephemushroom/opencode-claude-mem";
|
|
12722
|
+
function getConfigDir() {
|
|
12723
|
+
const custom2 = process.env["OPENCODE_CONFIG_DIR"]?.trim();
|
|
12724
|
+
return custom2 || join2(homedir2(), ".config", "opencode");
|
|
12725
|
+
}
|
|
12726
|
+
function readJson(filePath) {
|
|
12727
|
+
try {
|
|
12728
|
+
const parsed = JSON.parse(readFileSync2(filePath, "utf8"));
|
|
12729
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
12730
|
+
} catch {
|
|
12731
|
+
return null;
|
|
12732
|
+
}
|
|
12733
|
+
}
|
|
12734
|
+
function pluginEntries(config2) {
|
|
12735
|
+
const { plugin } = config2;
|
|
12736
|
+
if (!Array.isArray(plugin)) {
|
|
12737
|
+
return [];
|
|
12738
|
+
}
|
|
12739
|
+
return plugin.filter((entry) => typeof entry === "string");
|
|
12740
|
+
}
|
|
12741
|
+
function isOwnEntry(entry) {
|
|
12742
|
+
return entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`);
|
|
12743
|
+
}
|
|
12744
|
+
function findServerEntry(configDir) {
|
|
12745
|
+
for (const name of ["opencode.json", "opencode.jsonc"]) {
|
|
12746
|
+
const filePath = join2(configDir, name);
|
|
12747
|
+
const config2 = existsSync2(filePath) ? readJson(filePath) : null;
|
|
12748
|
+
const entry = config2 ? pluginEntries(config2).find(isOwnEntry) : undefined;
|
|
12749
|
+
if (entry) {
|
|
12750
|
+
return entry;
|
|
12751
|
+
}
|
|
12752
|
+
}
|
|
12753
|
+
return null;
|
|
12754
|
+
}
|
|
12755
|
+
function ensureTuiPluginEntry() {
|
|
12756
|
+
try {
|
|
12757
|
+
const configDir = getConfigDir();
|
|
12758
|
+
const serverEntry = findServerEntry(configDir);
|
|
12759
|
+
if (!serverEntry) {
|
|
12760
|
+
return "no-server-entry";
|
|
12761
|
+
}
|
|
12762
|
+
const tuiJsonPath = join2(configDir, "tui.json");
|
|
12763
|
+
let config2 = {};
|
|
12764
|
+
if (existsSync2(tuiJsonPath)) {
|
|
12765
|
+
const parsed = readJson(tuiJsonPath);
|
|
12766
|
+
if (!parsed) {
|
|
12767
|
+
return "malformed";
|
|
12768
|
+
}
|
|
12769
|
+
config2 = parsed;
|
|
12770
|
+
}
|
|
12771
|
+
const plugins = pluginEntries(config2);
|
|
12772
|
+
if (plugins.some(isOwnEntry)) {
|
|
12773
|
+
return "already-present";
|
|
12774
|
+
}
|
|
12775
|
+
mkdirSync(configDir, { recursive: true });
|
|
12776
|
+
const next = { ...config2, plugin: [...plugins, serverEntry] };
|
|
12777
|
+
writeFileSync(tuiJsonPath, `${JSON.stringify(next, null, 2)}
|
|
12778
|
+
`);
|
|
12779
|
+
return "added";
|
|
12780
|
+
} catch {
|
|
12781
|
+
return "failed";
|
|
12782
|
+
}
|
|
12783
|
+
}
|
|
12784
|
+
|
|
12599
12785
|
// src/index.ts
|
|
12600
12786
|
var MAX_OBSERVATION_BYTES = 24 * 1024;
|
|
12601
12787
|
var MAX_TAG_REPLACEMENTS = 100;
|
|
@@ -12605,7 +12791,9 @@ var META_TOOLS = new Set([
|
|
|
12605
12791
|
"getmcpresource",
|
|
12606
12792
|
"listmcpresourcestool",
|
|
12607
12793
|
"listmcptools",
|
|
12794
|
+
"mem-get-observations",
|
|
12608
12795
|
"mem-search",
|
|
12796
|
+
"mem-timeline",
|
|
12609
12797
|
"skill",
|
|
12610
12798
|
"slashcommand",
|
|
12611
12799
|
"todowrite"
|
|
@@ -12698,6 +12886,7 @@ function extractTextFromParts(parts) {
|
|
|
12698
12886
|
}
|
|
12699
12887
|
var ClaudeMemPlugin = async (ctx) => {
|
|
12700
12888
|
const { project, directory, client } = ctx;
|
|
12889
|
+
ensureTuiPluginEntry();
|
|
12701
12890
|
const projectRoot = directory || process.cwd();
|
|
12702
12891
|
const projectName = project?.worktree ? project.worktree.split(/[\\/]/).findLast(Boolean) || "unknown-project" : "unknown-project";
|
|
12703
12892
|
async function toast(message, variant = "info", duration3 = 3000) {
|
|
@@ -12842,6 +13031,46 @@ var ClaudeMemPlugin = async (ctx) => {
|
|
|
12842
13031
|
const result = await WorkerClient.search(query, projectName, limit);
|
|
12843
13032
|
return result || `No Claude-Mem results found for "${query}".`;
|
|
12844
13033
|
}
|
|
13034
|
+
}),
|
|
13035
|
+
"mem-timeline": tool({
|
|
13036
|
+
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.",
|
|
13037
|
+
args: {
|
|
13038
|
+
anchor: tool.schema.number().int().positive().optional().describe("Observation ID to center the timeline around"),
|
|
13039
|
+
query: tool.schema.string().min(1).optional().describe("Query to locate the anchor automatically (used when anchor is omitted)"),
|
|
13040
|
+
depth_before: tool.schema.number().int().min(0).max(20).optional().describe("Items before the anchor (default 3)"),
|
|
13041
|
+
depth_after: tool.schema.number().int().min(0).max(20).optional().describe("Items after the anchor (default 3)")
|
|
13042
|
+
},
|
|
13043
|
+
execute: async ({ anchor, query, depth_before, depth_after }) => {
|
|
13044
|
+
if (anchor === undefined && !query) {
|
|
13045
|
+
return "Provide either an anchor observation ID or a query.";
|
|
13046
|
+
}
|
|
13047
|
+
const isHealthy = await checkWorkerAndToast();
|
|
13048
|
+
if (!isHealthy) {
|
|
13049
|
+
return "Claude-Mem worker is offline. Start Claude-Mem and retry.";
|
|
13050
|
+
}
|
|
13051
|
+
const result = await WorkerClient.timeline({
|
|
13052
|
+
project: projectName,
|
|
13053
|
+
anchor,
|
|
13054
|
+
query,
|
|
13055
|
+
depthBefore: depth_before,
|
|
13056
|
+
depthAfter: depth_after
|
|
13057
|
+
});
|
|
13058
|
+
return result || "No Claude-Mem timeline results found.";
|
|
13059
|
+
}
|
|
13060
|
+
}),
|
|
13061
|
+
"mem-get-observations": tool({
|
|
13062
|
+
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.",
|
|
13063
|
+
args: {
|
|
13064
|
+
ids: tool.schema.array(tool.schema.number().int().positive()).min(1).max(50).describe("Observation IDs to fetch")
|
|
13065
|
+
},
|
|
13066
|
+
execute: async ({ ids }) => {
|
|
13067
|
+
const isHealthy = await checkWorkerAndToast();
|
|
13068
|
+
if (!isHealthy) {
|
|
13069
|
+
return "Claude-Mem worker is offline. Start Claude-Mem and retry.";
|
|
13070
|
+
}
|
|
13071
|
+
const result = await WorkerClient.getObservations(ids, projectName);
|
|
13072
|
+
return result || `No Claude-Mem observations found for IDs [${ids.join(", ")}].`;
|
|
13073
|
+
}
|
|
12845
13074
|
})
|
|
12846
13075
|
},
|
|
12847
13076
|
event: async ({ event }) => {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type TuiRegistrationResult = 'added' | 'already-present' | 'no-server-entry' | 'malformed' | 'failed';
|
|
2
|
+
/**
|
|
3
|
+
* Self-heal ~/.config/opencode/tui.json: if this plugin is registered as an
|
|
4
|
+
* OpenCode server plugin but missing from the TUI plugin list, append it so
|
|
5
|
+
* the Memory sidebar loads without manual config. Uses writeFileSync (not
|
|
6
|
+
* atomic rename) so a symlinked tui.json keeps pointing at its dotfiles
|
|
7
|
+
* target instead of being replaced by a regular file.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ensureTuiPluginEntry(): TuiRegistrationResult;
|
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
readonly recentSummaries: readonly {
|
|
10
|
+
id: number;
|
|
11
|
+
request: string;
|
|
12
|
+
}[];
|
|
13
|
+
readonly recentObservations: readonly {
|
|
14
|
+
id: number;
|
|
15
|
+
type: string;
|
|
16
|
+
title: string;
|
|
17
|
+
}[];
|
|
18
|
+
}
|
|
19
|
+
interface ViewNode {
|
|
20
|
+
readonly kind: 'box' | 'text';
|
|
21
|
+
readonly props: Record<string, unknown>;
|
|
22
|
+
readonly children?: readonly ViewNode[];
|
|
23
|
+
readonly text?: string;
|
|
24
|
+
}
|
|
25
|
+
interface Theme {
|
|
26
|
+
readonly text: string;
|
|
27
|
+
readonly textMuted: string;
|
|
28
|
+
readonly info: string;
|
|
29
|
+
readonly success: string;
|
|
30
|
+
readonly warning: string;
|
|
31
|
+
readonly error: string;
|
|
32
|
+
readonly borderSubtle: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function buildMemNodes(view: MemSidebarView, theme: Theme, collapsed: boolean, onToggle: () => void): ViewNode[];
|
|
35
|
+
export declare function viewKey(view: MemSidebarView): string;
|
|
36
|
+
export declare function readMemView(project: string, collapsed: boolean): Promise<MemSidebarView>;
|
|
37
|
+
declare const tuiModule: {
|
|
38
|
+
id: string;
|
|
39
|
+
tui: (api: any) => Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
export default tuiModule;
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
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 getRecentSummaries(project, limit) {
|
|
318
|
+
try {
|
|
319
|
+
const params = new URLSearchParams({ project, limit: String(limit) });
|
|
320
|
+
const response = await fetch(`${this.BASE_URL}/api/summaries?${params.toString()}`);
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
const payload = await response.json();
|
|
325
|
+
if (!Array.isArray(payload.items)) {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
return payload.items.flatMap((item) => {
|
|
329
|
+
if (!isRecord(item) || typeof item["id"] !== "number") {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
const request = typeof item["request"] === "string" ? item["request"].trim() : "";
|
|
333
|
+
return request ? [{ id: item["id"], request }] : [];
|
|
334
|
+
});
|
|
335
|
+
} catch {
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
static async getRecentObservations(project, limit) {
|
|
340
|
+
try {
|
|
341
|
+
const params = new URLSearchParams({ project, limit: String(limit) });
|
|
342
|
+
const response = await fetch(`${this.BASE_URL}/api/observations?${params.toString()}`);
|
|
343
|
+
if (!response.ok) {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
const payload = await response.json();
|
|
347
|
+
if (!Array.isArray(payload.items)) {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
return payload.items.flatMap((item) => {
|
|
351
|
+
if (!isRecord(item) || typeof item["id"] !== "number") {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
const title = typeof item["title"] === "string" ? item["title"].trim() : "";
|
|
355
|
+
const type = typeof item["type"] === "string" ? item["type"] : "";
|
|
356
|
+
return title ? [{ id: item["id"], type, title }] : [];
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
static async search(query, project, limit) {
|
|
363
|
+
try {
|
|
364
|
+
const params = new URLSearchParams({ query, project });
|
|
365
|
+
if (limit !== undefined) {
|
|
366
|
+
params.set("limit", String(limit));
|
|
367
|
+
}
|
|
368
|
+
const response = await fetch(`${this.BASE_URL}/api/search?${params.toString()}`);
|
|
369
|
+
if (!response.ok) {
|
|
370
|
+
return "";
|
|
371
|
+
}
|
|
372
|
+
const contentType = response.headers.get("content-type") || "";
|
|
373
|
+
if (contentType.includes("application/json")) {
|
|
374
|
+
return extractTextContent(await response.json()) || "";
|
|
375
|
+
}
|
|
376
|
+
const text = await response.text();
|
|
377
|
+
return text.trim();
|
|
378
|
+
} catch {
|
|
379
|
+
return "";
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/tui.ts
|
|
385
|
+
var POLL_INTERVAL_MS = 5000;
|
|
386
|
+
var LABEL_MAX = 40;
|
|
387
|
+
var RECENT_SUMMARIES = 3;
|
|
388
|
+
var RECENT_OBSERVATIONS = 3;
|
|
389
|
+
var OBSERVATION_TYPE_ICONS = {
|
|
390
|
+
bugfix: "\u25CF",
|
|
391
|
+
feature: "\u25C6",
|
|
392
|
+
refactor: "\u21BB",
|
|
393
|
+
change: "\u2713",
|
|
394
|
+
discovery: "\u25CB",
|
|
395
|
+
decision: "\u2696",
|
|
396
|
+
security_alert: "\u26A0",
|
|
397
|
+
security_note: "\u26B7"
|
|
398
|
+
};
|
|
399
|
+
function box(props, children) {
|
|
400
|
+
return { kind: "box", props, children };
|
|
401
|
+
}
|
|
402
|
+
function text(props, value) {
|
|
403
|
+
return { kind: "text", props, text: value };
|
|
404
|
+
}
|
|
405
|
+
var HTML_ENTITIES = {
|
|
406
|
+
"<": "<",
|
|
407
|
+
">": ">",
|
|
408
|
+
""": '"',
|
|
409
|
+
"'": "'",
|
|
410
|
+
"&": "&"
|
|
411
|
+
};
|
|
412
|
+
function decodeEntities(value) {
|
|
413
|
+
return value.replace(/&(?:lt|gt|quot|#39|amp);/g, (entity) => HTML_ENTITIES[entity] ?? entity);
|
|
414
|
+
}
|
|
415
|
+
function truncate(value) {
|
|
416
|
+
const decoded = decodeEntities(value);
|
|
417
|
+
return decoded.length <= LABEL_MAX ? decoded : `${decoded.slice(0, LABEL_MAX - 1)}\u2026`;
|
|
418
|
+
}
|
|
419
|
+
function formatCount(value) {
|
|
420
|
+
if (value === null) {
|
|
421
|
+
return "-";
|
|
422
|
+
}
|
|
423
|
+
return value >= 1e4 ? `${(value / 1000).toFixed(1)}k` : String(value);
|
|
424
|
+
}
|
|
425
|
+
function summaryLine(view) {
|
|
426
|
+
if (!view.healthy) {
|
|
427
|
+
return { label: "(offline)", tone: "err" };
|
|
428
|
+
}
|
|
429
|
+
if (view.isProcessing || (view.queueDepth ?? 0) > 0) {
|
|
430
|
+
return { label: `(queue ${view.queueDepth ?? 0})`, tone: "warn" };
|
|
431
|
+
}
|
|
432
|
+
return { label: `(online, ${formatCount(view.observations)} obs)`, tone: "ok" };
|
|
433
|
+
}
|
|
434
|
+
function toneColor(tone, theme) {
|
|
435
|
+
if (tone === "err") {
|
|
436
|
+
return theme.error;
|
|
437
|
+
}
|
|
438
|
+
return tone === "warn" ? theme.warning : theme.textMuted;
|
|
439
|
+
}
|
|
440
|
+
function bulletRow(icon, iconColor, label, theme) {
|
|
441
|
+
return box({ flexDirection: "row", gap: 1 }, [
|
|
442
|
+
text({ fg: iconColor, flexShrink: 0 }, icon),
|
|
443
|
+
text({ fg: theme.text, wrapMode: "none" }, truncate(label))
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
function expandedRows(view, theme) {
|
|
447
|
+
if (!view.healthy) {
|
|
448
|
+
return [bulletRow("\u2022", theme.error, "worker offline", theme)];
|
|
449
|
+
}
|
|
450
|
+
const rows = [
|
|
451
|
+
bulletRow("\u2022", theme.success, `obs ${formatCount(view.observations)} \xB7 sum ${formatCount(view.summaries)} \xB7 ses ${formatCount(view.sessions)}`, theme)
|
|
452
|
+
];
|
|
453
|
+
if (view.isProcessing || (view.queueDepth ?? 0) > 0) {
|
|
454
|
+
rows.push(bulletRow("\u2022", theme.warning, `processing \xB7 queue ${view.queueDepth ?? 0}`, theme));
|
|
455
|
+
}
|
|
456
|
+
if (view.recentSummaries.length > 0) {
|
|
457
|
+
rows.push(text({ fg: theme.textMuted }, "Recent sessions"));
|
|
458
|
+
for (const summary of view.recentSummaries) {
|
|
459
|
+
rows.push(bulletRow("\u2022", theme.textMuted, summary.request, theme));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (view.recentObservations.length > 0) {
|
|
463
|
+
rows.push(text({ fg: theme.textMuted }, "Latest"));
|
|
464
|
+
for (const observation of view.recentObservations) {
|
|
465
|
+
const icon = OBSERVATION_TYPE_ICONS[observation.type] ?? "\u25CB";
|
|
466
|
+
rows.push(bulletRow(icon, theme.info, observation.title, theme));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return rows;
|
|
470
|
+
}
|
|
471
|
+
function buildMemNodes(view, theme, collapsed, onToggle) {
|
|
472
|
+
const summary = summaryLine(view);
|
|
473
|
+
const header = box({ flexDirection: "row", gap: 1, onMouseDown: onToggle }, [
|
|
474
|
+
text({ fg: theme.text }, collapsed ? "\u25B6" : "\u25BC"),
|
|
475
|
+
text({ fg: theme.info }, "Memory"),
|
|
476
|
+
...collapsed ? [text({ fg: toneColor(summary.tone, theme) }, summary.label)] : []
|
|
477
|
+
]);
|
|
478
|
+
return [
|
|
479
|
+
box({ flexDirection: "column", paddingTop: 1 }, [
|
|
480
|
+
header,
|
|
481
|
+
...collapsed ? [] : [box({ flexDirection: "column", paddingLeft: 2 }, expandedRows(view, theme))]
|
|
482
|
+
])
|
|
483
|
+
];
|
|
484
|
+
}
|
|
485
|
+
function viewKey(view) {
|
|
486
|
+
return JSON.stringify(view);
|
|
487
|
+
}
|
|
488
|
+
function readCount(value) {
|
|
489
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
490
|
+
}
|
|
491
|
+
async function readMemView(project, collapsed) {
|
|
492
|
+
const stats = await WorkerClient.getStats();
|
|
493
|
+
if (!stats) {
|
|
494
|
+
return {
|
|
495
|
+
healthy: false,
|
|
496
|
+
project,
|
|
497
|
+
observations: null,
|
|
498
|
+
sessions: null,
|
|
499
|
+
summaries: null,
|
|
500
|
+
queueDepth: null,
|
|
501
|
+
isProcessing: false,
|
|
502
|
+
recentSummaries: [],
|
|
503
|
+
recentObservations: []
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const [recentSummaries, recentObservations] = collapsed ? [[], []] : await Promise.all([
|
|
507
|
+
WorkerClient.getRecentSummaries(project, RECENT_SUMMARIES),
|
|
508
|
+
WorkerClient.getRecentObservations(project, RECENT_OBSERVATIONS)
|
|
509
|
+
]);
|
|
510
|
+
return {
|
|
511
|
+
healthy: true,
|
|
512
|
+
project,
|
|
513
|
+
observations: readCount(stats.database?.observations),
|
|
514
|
+
sessions: readCount(stats.database?.sessions),
|
|
515
|
+
summaries: readCount(stats.database?.summaries),
|
|
516
|
+
queueDepth: readCount(stats.processing?.queueDepth),
|
|
517
|
+
isProcessing: stats.processing?.isProcessing === true,
|
|
518
|
+
recentSummaries,
|
|
519
|
+
recentObservations
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function materializeNode(node, solid) {
|
|
523
|
+
const element = solid.createElement(node.kind);
|
|
524
|
+
for (const [name, value] of Object.entries(node.props)) {
|
|
525
|
+
solid.setProp(element, name, value);
|
|
526
|
+
}
|
|
527
|
+
if (node.kind === "text") {
|
|
528
|
+
solid.insert(element, node.text ?? "");
|
|
529
|
+
}
|
|
530
|
+
for (const child of node.children ?? []) {
|
|
531
|
+
solid.insert(element, materializeNode(child, solid));
|
|
532
|
+
}
|
|
533
|
+
return element;
|
|
534
|
+
}
|
|
535
|
+
function materialize(nodes, solid) {
|
|
536
|
+
const root = solid.createElement("box");
|
|
537
|
+
solid.setProp(root, "flexDirection", "column");
|
|
538
|
+
for (const node of nodes) {
|
|
539
|
+
solid.insert(root, materializeNode(node, solid));
|
|
540
|
+
}
|
|
541
|
+
return root;
|
|
542
|
+
}
|
|
543
|
+
function projectNameFromDirectory(directory) {
|
|
544
|
+
return directory.split(/[\\/]/).findLast(Boolean) || "unknown-project";
|
|
545
|
+
}
|
|
546
|
+
var tuiModule = {
|
|
547
|
+
id: "opencode-claude-mem:tui",
|
|
548
|
+
tui: async (api) => {
|
|
549
|
+
const solid = await import("@opentui/solid").catch(() => null);
|
|
550
|
+
if (!solid) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const project = projectNameFromDirectory(String(api.state?.path?.directory ?? ""));
|
|
554
|
+
let collapsed = true;
|
|
555
|
+
let currentView = await readMemView(project, collapsed);
|
|
556
|
+
let currentKey = viewKey(currentView);
|
|
557
|
+
let disposed = false;
|
|
558
|
+
let inFlight = false;
|
|
559
|
+
let timer = null;
|
|
560
|
+
const refresh = async () => {
|
|
561
|
+
const nextView = await readMemView(project, collapsed);
|
|
562
|
+
const nextKey = viewKey(nextView);
|
|
563
|
+
if (nextKey !== currentKey) {
|
|
564
|
+
currentView = nextView;
|
|
565
|
+
currentKey = nextKey;
|
|
566
|
+
api.renderer.requestRender();
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const onToggle = () => {
|
|
570
|
+
collapsed = !collapsed;
|
|
571
|
+
api.renderer.requestRender();
|
|
572
|
+
refresh().catch(() => {});
|
|
573
|
+
};
|
|
574
|
+
api.slots.register({
|
|
575
|
+
order: 910,
|
|
576
|
+
slots: {
|
|
577
|
+
sidebar_content: () => materialize(buildMemNodes(currentView, api.theme.current, collapsed, onToggle), solid)
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
api.renderer.requestRender();
|
|
581
|
+
const schedule = () => {
|
|
582
|
+
timer = setTimeout(tick, POLL_INTERVAL_MS);
|
|
583
|
+
};
|
|
584
|
+
const tick = async () => {
|
|
585
|
+
if (disposed || inFlight) {
|
|
586
|
+
if (!disposed) {
|
|
587
|
+
schedule();
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
inFlight = true;
|
|
592
|
+
try {
|
|
593
|
+
await refresh();
|
|
594
|
+
} catch {} finally {
|
|
595
|
+
inFlight = false;
|
|
596
|
+
if (!disposed) {
|
|
597
|
+
schedule();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
schedule();
|
|
602
|
+
api.lifecycle.onDispose(() => {
|
|
603
|
+
disposed = true;
|
|
604
|
+
if (timer) {
|
|
605
|
+
clearTimeout(timer);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
var tui_default = tuiModule;
|
|
611
|
+
export {
|
|
612
|
+
viewKey,
|
|
613
|
+
readMemView,
|
|
614
|
+
tui_default as default,
|
|
615
|
+
buildMemNodes
|
|
616
|
+
};
|
package/dist/worker-client.d.ts
CHANGED
|
@@ -64,5 +64,51 @@ 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>;
|
|
98
|
+
/**
|
|
99
|
+
* Recent session summaries for the sidebar (GET /api/summaries).
|
|
100
|
+
*/
|
|
101
|
+
static getRecentSummaries(project: string, limit: number): Promise<{
|
|
102
|
+
id: number;
|
|
103
|
+
request: string;
|
|
104
|
+
}[]>;
|
|
105
|
+
/**
|
|
106
|
+
* Recent observations for the sidebar (GET /api/observations).
|
|
107
|
+
*/
|
|
108
|
+
static getRecentObservations(project: string, limit: number): Promise<{
|
|
109
|
+
id: number;
|
|
110
|
+
type: string;
|
|
111
|
+
title: string;
|
|
112
|
+
}[]>;
|
|
67
113
|
static search(query: string, project: string, limit?: number): Promise<string>;
|
|
68
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ephemushroom/opencode-claude-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|