feat(cli): 1.27.0 — state/memory through daemon + workspace alias

extend the daemon thin-client surface to two more verb families: state
get/set/list now routes through `/v1/state`, and remember/recall/forget
through `/v1/memory`. same warm-path pattern as 1.25.0 — try the unix
socket first, fall back to the cold ws path when the daemon is absent.
multi-mesh aware (aggregates on read, requires `--mesh` for writes
when ambiguous).

also ships an early `claudemesh workspace <verb>` alias surface — bare
teaser for the 1.28.0 mesh→workspace public rename. no-arg falls
through to launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-04 09:41:18 +01:00
parent cb90f1ca60
commit 3753a6e137
10 changed files with 523 additions and 9 deletions

View File

@@ -69,6 +69,21 @@ export interface SkillFull extends SkillSummary {
manifest?: unknown;
}
export interface StateRow {
key: string;
value: unknown;
updatedBy: string;
updatedAt: string;
}
export interface MemoryRow {
id: string;
content: string;
tags: string[];
rememberedBy: string;
rememberedAt: string;
}
const HELLO_ACK_TIMEOUT_MS = 5_000;
const SEND_ACK_TIMEOUT_MS = 15_000;
const BACKOFF_CAPS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
@@ -91,6 +106,10 @@ export class DaemonBrokerClient {
private peerListResolvers = new Map<string, PendingPeerList>();
private skillListResolvers = new Map<string, { resolve: (rows: SkillSummary[]) => void; timer: NodeJS.Timeout }>();
private skillDataResolvers = new Map<string, { resolve: (row: SkillFull | null) => void; timer: NodeJS.Timeout }>();
private stateGetResolvers = new Map<string, { resolve: (row: StateRow | null) => void; timer: NodeJS.Timeout }>();
private stateListResolvers = new Map<string, { resolve: (rows: StateRow[]) => void; timer: NodeJS.Timeout }>();
private memoryStoreResolvers = new Map<string, { resolve: (id: string | null) => void; timer: NodeJS.Timeout }>();
private memoryRecallResolvers = new Map<string, { resolve: (rows: MemoryRow[]) => void; timer: NodeJS.Timeout }>();
private sessionPubkey: string | null = null;
private sessionSecretKey: string | null = null;
private opens: Array<() => void> = [];
@@ -226,6 +245,50 @@ export class DaemonBrokerClient {
return;
}
if (msg.type === "state_value" || msg.type === "state_data") {
const reqId = String(msg._reqId ?? "");
const pending = this.stateGetResolvers.get(reqId);
if (pending) {
this.stateGetResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve((msg.state ?? msg.row ?? null) as StateRow | null);
}
return;
}
if (msg.type === "state_list") {
const reqId = String(msg._reqId ?? "");
const pending = this.stateListResolvers.get(reqId);
if (pending) {
this.stateListResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve(Array.isArray(msg.entries) ? (msg.entries as StateRow[]) : []);
}
return;
}
if (msg.type === "memory_stored") {
const reqId = String(msg._reqId ?? "");
const pending = this.memoryStoreResolvers.get(reqId);
if (pending) {
this.memoryStoreResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve(typeof msg.memoryId === "string" ? msg.memoryId : null);
}
return;
}
if (msg.type === "memory_recall_result") {
const reqId = String(msg._reqId ?? "");
const pending = this.memoryRecallResolvers.get(reqId);
if (pending) {
this.memoryRecallResolvers.delete(reqId);
clearTimeout(pending.timer);
pending.resolve(Array.isArray(msg.matches) ? (msg.matches as MemoryRow[]) : []);
}
return;
}
if (msg.type === "push" || msg.type === "inbound") {
this.opts.onPush?.(msg);
return;
@@ -329,6 +392,76 @@ export class DaemonBrokerClient {
});
}
/** Read a single shared state row. Null on disconnect / timeout / not-found. */
async getState(key: string, timeoutMs = 5_000): Promise<StateRow | null> {
if (this._status !== "open" || !this.ws) return null;
return new Promise<StateRow | null>((resolve) => {
const reqId = `sg-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.stateGetResolvers.delete(reqId)) resolve(null);
}, timeoutMs);
this.stateGetResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "get_state", key, _reqId: reqId })); }
catch { this.stateGetResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
});
}
/** List all shared state rows in the mesh. */
async listState(timeoutMs = 5_000): Promise<StateRow[]> {
if (this._status !== "open" || !this.ws) return [];
return new Promise<StateRow[]>((resolve) => {
const reqId = `sl-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.stateListResolvers.delete(reqId)) resolve([]);
}, timeoutMs);
this.stateListResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "list_state", _reqId: reqId })); }
catch { this.stateListResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
});
}
/** Set a shared state value. Fire-and-forget. */
setState(key: string, value: unknown): void {
if (this._status !== "open" || !this.ws) return;
try { this.ws.send(JSON.stringify({ type: "set_state", key, value })); }
catch { /* ignore */ }
}
/** Store a memory in the mesh. Returns the assigned id, or null on timeout. */
async remember(content: string, tags?: string[], timeoutMs = 5_000): Promise<string | null> {
if (this._status !== "open" || !this.ws) return null;
return new Promise<string | null>((resolve) => {
const reqId = `mr-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.memoryStoreResolvers.delete(reqId)) resolve(null);
}, timeoutMs);
this.memoryStoreResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "remember", content, tags, _reqId: reqId })); }
catch { this.memoryStoreResolvers.delete(reqId); clearTimeout(timer); resolve(null); }
});
}
/** Search memories by relevance. */
async recall(query: string, timeoutMs = 5_000): Promise<MemoryRow[]> {
if (this._status !== "open" || !this.ws) return [];
return new Promise<MemoryRow[]>((resolve) => {
const reqId = `mc-${++this.reqCounter}`;
const timer = setTimeout(() => {
if (this.memoryRecallResolvers.delete(reqId)) resolve([]);
}, timeoutMs);
this.memoryRecallResolvers.set(reqId, { resolve, timer });
try { this.ws!.send(JSON.stringify({ type: "recall", query, _reqId: reqId })); }
catch { this.memoryRecallResolvers.delete(reqId); clearTimeout(timer); resolve([]); }
});
}
/** Forget a memory by id. Fire-and-forget. */
forget(memoryId: string): void {
if (this._status !== "open" || !this.ws) return;
try { this.ws.send(JSON.stringify({ type: "forget", memoryId })); }
catch { /* ignore */ }
}
/** Set the daemon's profile (avatar/title/bio/capabilities). Fire-and-forget. */
setProfile(profile: { avatar?: string; title?: string; bio?: string; capabilities?: string[] }): void {
if (this._status !== "open" || !this.ws) return;

View File

@@ -177,7 +177,7 @@ function makeHandler(opts: {
respond(res, 200, {
daemon_version: VERSION,
ipc_api: "v1",
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills"],
ipc_features: ["version", "health", "send", "inbox", "events", "peers", "profile", "skills", "state", "memory"],
schema_version: 1,
});
return;
@@ -224,6 +224,139 @@ function makeHandler(opts: {
return;
}
if (req.method === "GET" && url.pathname === "/v1/state") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
const filterMesh = url.searchParams.get("mesh") ?? undefined;
const key = url.searchParams.get("key");
try {
if (key) {
// Single key lookup. Walk attached meshes; first match wins
// (or ?mesh=<slug> scopes the search).
for (const [slug, b] of opts.brokers.entries()) {
if (filterMesh && filterMesh !== slug) continue;
const row = await b.getState(key).catch(() => null);
if (row) { respond(res, 200, { state: { ...row, mesh: slug } }); return; }
}
respond(res, 404, { error: "state_not_found", key });
return;
}
// No key — list all entries across attached meshes.
const all: Array<Record<string, unknown> & { mesh: string }> = [];
for (const [slug, b] of opts.brokers.entries()) {
if (filterMesh && filterMesh !== slug) continue;
const rows = await b.listState().catch(() => []);
for (const r of rows) all.push({ ...(r as unknown as Record<string, unknown>), mesh: slug });
}
respond(res, 200, { entries: all });
} catch (e) {
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
}
return;
}
if (req.method === "POST" && url.pathname === "/v1/state") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
try {
const body = await readJsonBody(req, 256 * 1024) as Record<string, unknown> | null;
if (!body || typeof body.key !== "string") {
respond(res, 400, { error: "missing 'key' (string)" });
return;
}
const requested = (typeof body.mesh === "string" ? body.mesh : null) || null;
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
return;
}
const broker = opts.brokers.get(chosen);
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
broker.setState(body.key, body.value);
respond(res, 200, { ok: true, key: body.key, mesh: chosen });
} catch (e) {
respond(res, 400, { error: String(e) });
}
return;
}
if (req.method === "GET" && url.pathname === "/v1/memory") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
const query = url.searchParams.get("q") ?? "";
const filterMesh = url.searchParams.get("mesh") ?? undefined;
try {
const all: Array<Record<string, unknown> & { mesh: string }> = [];
for (const [slug, b] of opts.brokers.entries()) {
if (filterMesh && filterMesh !== slug) continue;
const rows = await b.recall(query).catch(() => []);
for (const r of rows) all.push({ ...(r as unknown as Record<string, unknown>), mesh: slug });
}
respond(res, 200, { matches: all });
} catch (e) {
respond(res, 502, { error: "broker_unreachable", detail: String(e) });
}
return;
}
if (req.method === "POST" && url.pathname === "/v1/memory") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
try {
const body = await readJsonBody(req, 256 * 1024) as Record<string, unknown> | null;
if (!body || typeof body.content !== "string") {
respond(res, 400, { error: "missing 'content' (string)" });
return;
}
const requested = (typeof body.mesh === "string" ? body.mesh : null) || null;
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
return;
}
const broker = opts.brokers.get(chosen);
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
const tags = Array.isArray(body.tags) ? body.tags.filter((t) => typeof t === "string") as string[] : undefined;
const id = await broker.remember(body.content, tags);
if (!id) { respond(res, 502, { error: "remember_timeout" }); return; }
respond(res, 200, { id, mesh: chosen });
} catch (e) {
respond(res, 400, { error: String(e) });
}
return;
}
if (req.method === "DELETE" && url.pathname.startsWith("/v1/memory/")) {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });
return;
}
const id = decodeURIComponent(url.pathname.slice("/v1/memory/".length));
if (!id) { respond(res, 400, { error: "missing memory id" }); return; }
const requested = url.searchParams.get("mesh");
let chosen = requested;
if (!chosen && opts.brokers.size === 1) chosen = opts.brokers.keys().next().value as string;
if (!chosen) {
respond(res, 400, { error: "mesh_required", attached: [...opts.brokers.keys()] });
return;
}
const broker = opts.brokers.get(chosen);
if (!broker) { respond(res, 404, { error: "mesh_not_attached", mesh: chosen }); return; }
broker.forget(id);
respond(res, 200, { ok: true, id, mesh: chosen });
return;
}
if (req.method === "GET" && url.pathname === "/v1/skills") {
if (!opts.brokers || opts.brokers.size === 0) {
respond(res, 503, { error: "broker not initialised" });