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:
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user