Skip to content

Commit a85ad74

Browse files
committed
feat: Add OpenCode TUI health companion
1 parent df2fc21 commit a85ad74

23 files changed

Lines changed: 1018 additions & 974 deletions

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ Add the plugin to `opencode.json` or `opencode.jsonc`:
2828
}
2929
```
3030

31+
The package also includes an OpenCode TUI companion. Enable it in `tui.json`:
32+
33+
```json
34+
{
35+
"$schema": "https://opencode.ai/tui.json",
36+
"plugin": ["@nguyenthdat/opencode-memory@0.6.0-beta.0"]
37+
}
38+
```
39+
40+
Use the project `.opencode/tui.json` or the user-level `~/.config/opencode/tui.json` depending on the desired scope. The companion displays `Memory: checking`, `Memory: healthy`, `Memory: degraded`, or `Memory: unavailable` in the TUI bottom bar. Run `/memory-health` to refresh it and show the detailed result as a toast. OpenCode's plugin installer can configure both the server and TUI entrypoints from the same package.
41+
3142
On a supported platform, npm installs one matching optional native package. Reinstall without `--omit=optional`; the plugin intentionally has no postinstall script or runtime binary download.
3243

3344
### How the daemon is installed
@@ -36,7 +47,7 @@ The plugin does not install a global daemon, `systemd` unit, `launchd` service,
3647

3748
When OpenCode loads the TypeScript plugin, no native process is started yet. On the first memory request, the plugin resolves the executable from the installed native package, validates the private per-user runtime directory and socket, and connects to an existing compatible daemon when one is already running. If no daemon is available, one contender acquires the startup lock and launches the packaged executable in detached `--daemon` mode; concurrent OpenCode processes wait for the same endpoint instead of starting additional daemons.
3849

39-
The daemon is therefore installed and upgraded as part of the npm plugin dependency graph, but started lazily at runtime. It exits automatically after the configured idle interval and is started again on the next memory request. Set `OPENCODE_NATIVE_MEMORY_BIN` only for a development binary override, or `OPENCODE_MEMORY_TRANSPORT=sidecar` for the temporary beta rollback path.
50+
The daemon is therefore installed and upgraded as part of the npm plugin dependency graph, but started lazily at runtime. It exits automatically after the configured idle interval and is started again on the next memory request. Set `OPENCODE_NATIVE_MEMORY_BIN` only for a development binary override.
4051

4152
Supported packages:
4253

@@ -72,7 +83,7 @@ The plugin automatically registers its packaged `rules/native-memory.md` as an O
7283
| `memory_import` | Validate and restore a portable JSON snapshot |
7384
| `memory_feedback` | Record whether recalled memories were useful |
7485
| `memory_optimize` | Prune expired records and optimize indexes |
75-
| `memory_status` | Inspect backend, model, and schema status |
86+
| `memory_status` | Health-check the plugin and inspect backend, model, and schema status |
7687
| `memory_doctor` | Run shallow or deep integrity checks |
7788
| `memory_purge` | Confirm and delete the complete project store |
7889

@@ -119,7 +130,6 @@ Changing model identity or vector-affecting preprocessing requires rebuilding th
119130
| `OPENCODE_MEMORY_MODEL_CACHE` | Replace the complete local Hugging Face model-cache path |
120131
| `OPENCODE_MEMORY_REQUEST_TIMEOUT_MS` | Native RPC timeout in milliseconds; default 5 minutes, maximum 2 hours |
121132
| `OPENCODE_NATIVE_MEMORY_BIN` | Development/debug native daemon binary override |
122-
| `OPENCODE_MEMORY_TRANSPORT` | `daemon` by default; temporary `sidecar` beta rollback |
123133
| `OPENCODE_MEMORY_PROJECT_IDLE_SECONDS` | Release an unleased project actor after 5 minutes |
124134
| `OPENCODE_MEMORY_DAEMON_IDLE_SECONDS` | Stop the daemon after 10 minutes with no sessions or project activity |
125135
| `OPENCODE_MEMORY_WARMUP` | Enable model/shared-memory warmup; default `true` |

bun.lock

Lines changed: 220 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

opencode-memory/src/contracts.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,48 @@ export interface DocumentIndexResponse {
165165
warnings: string[];
166166
}
167167

168+
export interface NativeMemoryStatus {
169+
ready: boolean;
170+
rpc_protocol_version: number;
171+
backend: string;
172+
zvec_version: string;
173+
embedding_model: string;
174+
embedding_dimension: number;
175+
project_root: string;
176+
project_id: string;
177+
collection_path: string;
178+
document_count: number;
179+
indexed_document_count: number;
180+
state_schema_version: number;
181+
metadata_count: number;
182+
tombstone_count: number;
183+
retrieval_count: number;
184+
pending_upsert_count: number;
185+
pending_delete_count: number;
186+
indexes: Array<{ name: string; completeness: number }>;
187+
capabilities: string[];
188+
}
189+
190+
export type MemoryPluginHealthStatus = "healthy" | "degraded" | "unavailable";
191+
192+
export interface MemoryPluginHealthIssue {
193+
component: "backend" | "shared_sync" | "document_index";
194+
message: string;
195+
}
196+
197+
export interface MemoryPluginHealth {
198+
status: MemoryPluginHealthStatus;
199+
ready: boolean;
200+
checked_at_ms: number;
201+
issues: MemoryPluginHealthIssue[];
202+
}
203+
204+
export type MemoryStatusResponse = Record<string, unknown> &
205+
(
206+
| (NativeMemoryStatus & { plugin_health: MemoryPluginHealth })
207+
| { plugin_health: MemoryPluginHealth }
208+
);
209+
168210
export interface SharedMemoryRecord extends CuratedCandidate {
169211
source: string;
170212
}
@@ -193,11 +235,3 @@ export interface RpcResponse {
193235
result?: unknown | undefined;
194236
error?: string | undefined;
195237
}
196-
197-
export interface PendingRequest {
198-
resolve(value: unknown): void;
199-
reject(error: Error): void;
200-
timer: ReturnType<typeof setTimeout>;
201-
abort?: (() => void) | undefined;
202-
signal?: AbortSignal | undefined;
203-
}

opencode-memory/src/daemon-client.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,8 @@ describe("shared daemon client", () => {
3232
});
3333

3434
test("rejects invalid custom request timeouts before opening a transport", () => {
35-
expect(() => new NativeMemoryClient(".", ".", undefined, 0)).toThrow("request timeout");
36-
expect(() => new NativeMemoryClient(".", ".", undefined, Number.NaN)).toThrow(
37-
"request timeout",
38-
);
35+
expect(() => new NativeMemoryClient(".", ".", 0)).toThrow("request timeout");
36+
expect(() => new NativeMemoryClient(".", ".", Number.NaN)).toThrow("request timeout");
3937
});
4038

4139
test("releases the shared project client only after the final local lease", async () => {

opencode-memory/src/daemon-client.ts

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from "node:child_process";
22
import { randomUUID } from "node:crypto";
3+
import { existsSync, realpathSync } from "node:fs";
34
import {
45
chmod,
56
lstat,
@@ -10,6 +11,7 @@ import {
1011
unlink,
1112
type FileHandle,
1213
} from "node:fs/promises";
14+
import { createRequire } from "node:module";
1315
import { tmpdir } from "node:os";
1416
import { dirname, join, resolve } from "node:path";
1517
import { createConnection, type Socket } from "node:net";
@@ -36,11 +38,6 @@ import type {
3638
OpenSessionResponse,
3739
} from "./generated/opencode/memory/daemon/v1/daemon_pb.js";
3840
import type { Response as MemoryResponse } from "./generated/opencode/memory/v1/memory_pb.js";
39-
import {
40-
NativeMemoryClient as LegacySidecarClient,
41-
resolveNativeMemoryBinary,
42-
} from "./sidecar-client.js";
43-
import type { SpawnFn } from "./sidecar-client.js";
4441
import {
4542
createMemoryRequest,
4643
decodeMemoryResponse,
@@ -59,6 +56,12 @@ const START_LOCK_STALE_MS = 30_000;
5956
const START_LOCK_TIMEOUT_MS = START_LOCK_STALE_MS + STARTUP_TIMEOUT_MS;
6057
const DAEMON_PROTOCOL_GENERATION = 1;
6158
const DOMAIN_SCHEMA_GENERATION = 1;
59+
const require = createRequire(import.meta.url);
60+
const NATIVE_PACKAGES: Partial<Record<string, string>> = {
61+
"darwin-arm64": "@nguyenthdat/opencode-memory-darwin-arm64",
62+
"linux-arm64": "@nguyenthdat/opencode-memory-linux-arm64-gnu",
63+
"linux-x64": "@nguyenthdat/opencode-memory-linux-x64-gnu",
64+
};
6265

6366
export const MAX_REQUEST_BYTES = 32 * MiB;
6467
export const MAX_RESPONSE_BYTES = 32 * MiB;
@@ -71,10 +74,6 @@ export interface NativeMemoryRequester {
7174
request<T>(method: MemoryMethod, params?: unknown, signal?: AbortSignal): Promise<T>;
7275
}
7376

74-
interface MemoryClientDelegate extends NativeMemoryRequester {
75-
dispose(): Promise<void>;
76-
}
77-
7877
interface PendingDaemonRequest {
7978
resolve(response: DaemonResponse): void;
8079
reject(error: Error): void;
@@ -145,7 +144,7 @@ class NativeMemoryOperationError extends Error {
145144
readonly name = "NativeMemoryOperationError";
146145
}
147146

148-
class DaemonProjectClient implements MemoryClientDelegate {
147+
class DaemonProjectClient implements NativeMemoryRequester {
149148
private socket: Socket | undefined;
150149
private generation = 0;
151150
private ready: ReadyProject | undefined;
@@ -661,41 +660,23 @@ function createDaemonRequest(body: DaemonRequestMessage["body"], requestId = ran
661660
});
662661
}
663662

664-
export class NativeMemoryClient implements MemoryClientDelegate {
665-
private readonly daemon: DaemonProjectClient | undefined;
666-
private readonly delegate: MemoryClientDelegate;
663+
export class NativeMemoryClient {
664+
private readonly daemon: DaemonProjectClient;
667665

668-
constructor(
669-
root: string,
670-
worktree: string,
671-
spawnOverride?: SpawnFn,
672-
requestTimeoutMs = REQUEST_TIMEOUT_MS,
673-
) {
666+
constructor(root: string, worktree: string, requestTimeoutMs = REQUEST_TIMEOUT_MS) {
674667
validateRequestTimeout(requestTimeoutMs);
675-
const transport = process.env.OPENCODE_MEMORY_TRANSPORT ?? "daemon";
676-
if (transport !== "daemon" && transport !== "sidecar") {
677-
throw new Error(
678-
`Invalid OPENCODE_MEMORY_TRANSPORT: expected daemon or sidecar, received ${transport}`,
679-
);
680-
}
681-
if (spawnOverride || transport === "sidecar") {
682-
this.delegate = new LegacySidecarClient(root, worktree, spawnOverride, requestTimeoutMs);
683-
return;
684-
}
685668
this.daemon = new DaemonProjectClient(root, worktree, requestTimeoutMs);
686-
this.delegate = this.daemon;
687669
}
688670

689671
request<T>(method: MemoryMethod, params: unknown = {}, signal?: AbortSignal): Promise<T> {
690-
return this.delegate.request<T>(method, params, signal);
672+
return this.daemon.request<T>(method, params, signal);
691673
}
692674

693675
dispose(): Promise<void> {
694-
return this.delegate.dispose();
676+
return this.daemon.dispose();
695677
}
696678

697679
async daemonInfo(): Promise<DaemonClientInfo> {
698-
if (!this.daemon) throw new Error("Native memory client is using the legacy sidecar transport");
699680
return await this.daemon.info();
700681
}
701682
}
@@ -793,6 +774,51 @@ export function resolveDaemonEndpoint(): string {
793774
: join("/tmp", `opencode-memory-${uid}`, "daemon.sock");
794775
}
795776

777+
export function resolveNativeMemoryBinary(root: string): string {
778+
const platform = `${process.platform}-${process.arch}`;
779+
const packageName = NATIVE_PACKAGES[platform];
780+
if (!packageName) {
781+
throw new Error(
782+
`Native memory supports only macOS arm64 and glibc Linux arm64/x64, not ${platform}`,
783+
);
784+
}
785+
const override = process.env.OPENCODE_NATIVE_MEMORY_BIN;
786+
const binaryName = "opencode-memory";
787+
const packaged = resolvePackagedBinary(packageName, binaryName);
788+
const candidates = override
789+
? [resolve(override)]
790+
: [
791+
resolve(root, "target", "release", binaryName),
792+
resolve(root, "target", "debug", binaryName),
793+
...(packaged ? [packaged] : []),
794+
];
795+
for (const candidate of candidates) {
796+
if (!existsSync(candidate)) continue;
797+
const binary = realpathSync(candidate);
798+
if (!override) {
799+
const library = resolve(
800+
binary,
801+
"..",
802+
"memory-libs",
803+
process.platform === "darwin" ? "libzvec_c_api.dylib" : "libzvec_c_api.so",
804+
);
805+
if (!existsSync(library)) continue;
806+
}
807+
return binary;
808+
}
809+
throw new Error(
810+
`Native memory binary was not found. Reinstall with optional dependencies or run \`bun run build:native:release\`. Checked: ${candidates.join(", ")}`,
811+
);
812+
}
813+
814+
function resolvePackagedBinary(packageName: string, binaryName: string): string | undefined {
815+
try {
816+
return require.resolve(`${packageName}/bin/${binaryName}`);
817+
} catch {
818+
return undefined;
819+
}
820+
}
821+
796822
async function bootstrapDaemon(root: string, endpoint: string): Promise<void> {
797823
const runtimeDirectory = dirname(endpoint);
798824
await ensurePrivateRuntimeDirectory(runtimeDirectory);
@@ -1127,6 +1153,3 @@ function asError(error: unknown): Error {
11271153
function delay(milliseconds: number): Promise<void> {
11281154
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
11291155
}
1130-
1131-
export { resolveNativeMemoryBinary };
1132-
export type { SpawnFn };

opencode-memory/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export type {
3434
CuratedCandidate,
3535
SharedMemoryRecord,
3636
SharedSyncResponse,
37+
NativeMemoryStatus,
38+
MemoryPluginHealthStatus,
39+
MemoryPluginHealthIssue,
40+
MemoryPluginHealth,
41+
MemoryStatusResponse,
3742
} from "./contracts.js";
3843

3944
// Shared daemon client
@@ -55,7 +60,6 @@ export type {
5560
DaemonControlInfo,
5661
NativeMemoryClientLease,
5762
NativeMemoryRequester,
58-
SpawnFn,
5963
} from "./daemon-client.js";
6064

6165
export {

0 commit comments

Comments
 (0)