Skip to content

Commit 38f717b

Browse files
authored
feat(commonly): forward-port runtime collaboration tools (#10)
feat(commonly): forward-port runtime collaboration tools
2 parents 0082147 + 70bd82b commit 38f717b

5 files changed

Lines changed: 682 additions & 16 deletions

File tree

Dockerfile

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,18 +214,20 @@ RUN if [ -n "$OPENCLAW_INSTALL_GH_CLI" ]; then \
214214
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
215215
fi
216216

217-
# Optionally install the officecli binary so agents can generate real
218-
# .docx/.xlsx/.pptx artifacts via the officecli bundled skill.
217+
# Optionally install document generation and extraction tools for the Commonly
218+
# attachment flow. OfficeCLI creates and reads Office documents; poppler and
219+
# markitdown cover the PDF and fallback extraction paths in
220+
# commonly_read_attachment.
219221
# Build with: docker build --build-arg OPENCLAW_INSTALL_DOC_TOOLCHAIN=1 ...
220-
# officecli is a single self-contained binary (no LibreOffice/pandoc needed).
222+
# OfficeCLI is a single self-contained binary (no LibreOffice/pandoc needed).
221223
# Installed to /usr/local/bin so it is on PATH for every agent user, rather
222224
# than the install script's default $HOME/.local/bin (not on the agent PATH,
223225
# and unreadable if the runtime drops privileges). ~50MB.
224226
ARG OPENCLAW_INSTALL_DOC_TOOLCHAIN=""
225227
RUN if [ -n "$OPENCLAW_INSTALL_DOC_TOOLCHAIN" ]; then \
226228
apt-get update && \
227229
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
228-
ca-certificates curl && \
230+
ca-certificates curl poppler-utils python3 python3-pip && \
229231
arch="$(dpkg --print-architecture)" && \
230232
case "$arch" in \
231233
amd64) asset="officecli-linux-x64" ;; \
@@ -236,6 +238,9 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOC_TOOLCHAIN" ]; then \
236238
-o /usr/local/bin/officecli && \
237239
chmod +x /usr/local/bin/officecli && \
238240
officecli --version && \
241+
pip3 install --break-system-packages --no-cache-dir markitdown pypdf && \
242+
pdftotext -v >/dev/null 2>&1 && \
243+
python3 -c "import markitdown, pypdf" && \
239244
apt-get clean && \
240245
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
241246
fi

extensions/commonly/src/client.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,108 @@ describe("CommonlyClient", () => {
119119
await expect(client.reactToMessage("msg-42", "🎉")).rejects.toThrow(/403/);
120120
});
121121
});
122+
123+
describe("typed agent memory", () => {
124+
it("reads the v1 blob and v2 typed sections together", async () => {
125+
fetchMock.mockResolvedValue(
126+
createResponse({
127+
content: "legacy",
128+
sections: { long_term: { content: "curated" } },
129+
sourceRuntime: "openclaw",
130+
schemaVersion: 2,
131+
}),
132+
);
133+
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });
134+
135+
const result = await client.readAgentMemory();
136+
137+
expect(result.content).toBe("legacy");
138+
expect(result.sections?.long_term?.content).toBe("curated");
139+
expect(result.sourceRuntime).toBe("openclaw");
140+
expect(result.schemaVersion).toBe(2);
141+
});
142+
143+
it("syncs a typed memory patch through the runtime endpoint", async () => {
144+
fetchMock.mockResolvedValue(createResponse({ ok: true, schemaVersion: 2 }));
145+
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });
146+
147+
await client.syncAgentMemory(
148+
{ long_term: { content: "remember this" } },
149+
{ mode: "patch", sourceRuntime: "openclaw" },
150+
);
151+
152+
expect(fetchMock).toHaveBeenCalledWith(
153+
"http://localhost:5000/api/agents/runtime/memory/sync",
154+
expect.objectContaining({
155+
method: "POST",
156+
headers: expect.objectContaining({ Authorization: "Bearer rt" }),
157+
}),
158+
);
159+
expect(JSON.parse((fetchMock.mock.calls[0]![1] as { body: string }).body)).toEqual({
160+
sections: { long_term: { content: "remember this" } },
161+
mode: "patch",
162+
sourceRuntime: "openclaw",
163+
});
164+
});
165+
166+
it("preserves the append-only cycles payload shape", async () => {
167+
fetchMock.mockResolvedValue(createResponse({ ok: true, cyclesAppended: true }));
168+
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });
169+
170+
await client.syncAgentMemory(
171+
{ cycles: { append: { content: "verified a port", podId: "pod-1" } } },
172+
{ mode: "patch", sourceRuntime: "openclaw" },
173+
);
174+
175+
expect(JSON.parse((fetchMock.mock.calls[0]![1] as { body: string }).body)).toEqual({
176+
sections: { cycles: { append: { content: "verified a port", podId: "pod-1" } } },
177+
mode: "patch",
178+
sourceRuntime: "openclaw",
179+
});
180+
});
181+
});
182+
183+
describe("agent DM", () => {
184+
it("opens a DM with its target identity and runtime token", async () => {
185+
fetchMock.mockResolvedValue(
186+
createResponse({ room: { _id: "dm-1", name: "Peer" }, autoJoined: false }),
187+
);
188+
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });
189+
190+
const result = await client.openAgentDm(
191+
{ agentName: "openclaw", instanceId: "peer" },
192+
"pod-1",
193+
);
194+
195+
expect(result.room._id).toBe("dm-1");
196+
expect(fetchMock).toHaveBeenCalledWith(
197+
"http://localhost:5000/api/agents/runtime/agent-dm",
198+
expect.objectContaining({
199+
method: "POST",
200+
headers: expect.objectContaining({ Authorization: "Bearer rt" }),
201+
}),
202+
);
203+
expect(JSON.parse((fetchMock.mock.calls[0]![1] as { body: string }).body)).toEqual({
204+
target: { agentName: "openclaw", instanceId: "peer" },
205+
originPodId: "pod-1",
206+
});
207+
});
208+
});
209+
210+
it("reads a pod attachment with the runtime token", async () => {
211+
const bytes = new TextEncoder().encode("attachment text");
212+
fetchMock.mockResolvedValue({
213+
ok: true,
214+
arrayBuffer: async () => bytes.buffer,
215+
});
216+
const client = new CommonlyClient({ baseUrl: "http://localhost:5000", runtimeToken: "rt" });
217+
218+
await expect(client.readAttachment("brief.txt")).resolves.toEqual(
219+
Buffer.from("attachment text"),
220+
);
221+
expect(fetchMock).toHaveBeenCalledWith(
222+
"http://localhost:5000/api/uploads/brief.txt",
223+
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer rt" }) }),
224+
);
225+
});
122226
});

extensions/commonly/src/client.ts

Lines changed: 143 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,53 @@ export interface CommonlyClientConfig {
1515
instanceId?: string;
1616
}
1717

18+
// ADR-003's typed memory envelope. `cycles` has a separate append-only write
19+
// shape, so it is deliberately excluded from MemorySectionName below.
20+
export type MemoryVisibility = "private" | "pod" | "public";
21+
22+
export interface MemorySection {
23+
content: string;
24+
visibility?: MemoryVisibility;
25+
byteSize?: number;
26+
updatedAt?: string;
27+
}
28+
29+
export interface DailySection {
30+
date: string;
31+
content: string;
32+
visibility?: MemoryVisibility;
33+
}
34+
35+
export interface RelationshipNote {
36+
otherInstanceId: string;
37+
notes?: string;
38+
visibility?: MemoryVisibility;
39+
updatedAt?: string;
40+
}
41+
42+
export interface CycleEntry {
43+
content: string;
44+
ts?: string;
45+
podId?: string;
46+
}
47+
48+
export interface AgentMemorySections {
49+
soul?: MemorySection;
50+
long_term?: MemorySection;
51+
daily?: DailySection[];
52+
dedup_state?: MemorySection;
53+
relationships?: RelationshipNote[];
54+
shared?: MemorySection;
55+
runtime_meta?: MemorySection;
56+
cycles?: { entries?: CycleEntry[]; visibility?: MemoryVisibility };
57+
}
58+
59+
export type MemorySectionName = Exclude<keyof AgentMemorySections, "cycles">;
60+
61+
export type AgentMemorySyncSections = Omit<AgentMemorySections, "cycles"> & {
62+
cycles?: { append: CycleEntry };
63+
};
64+
1865
export interface PodContext {
1966
pod?: {
2067
name: string;
@@ -196,14 +243,11 @@ export class CommonlyClient {
196243
form.append("podId", podId);
197244

198245
// NOTE: do not set Content-Type — fetch derives the multipart boundary.
199-
const res = await fetch(
200-
`${this.config.baseUrl}/api/agents/runtime/pods/${podId}/uploads`,
201-
{
202-
method: "POST",
203-
headers: { Authorization: `Bearer ${token}` },
204-
body: form,
205-
},
206-
);
246+
const res = await fetch(`${this.config.baseUrl}/api/agents/runtime/pods/${podId}/uploads`, {
247+
method: "POST",
248+
headers: { Authorization: `Bearer ${token}` },
249+
body: form,
250+
});
207251
if (!res.ok) {
208252
let detail = `${res.status}`;
209253
try {
@@ -487,9 +531,15 @@ export class CommonlyClient {
487531
}
488532

489533
/**
490-
* Read this agent's personal MEMORY.md (stored in backend, persistent across sessions)
534+
* Read this agent's personal memory. The v1 content blob remains for
535+
* compatibility; new callers should use the typed sections envelope.
491536
*/
492-
async readAgentMemory(): Promise<{ content: string }> {
537+
async readAgentMemory(): Promise<{
538+
content: string;
539+
sections?: AgentMemorySections;
540+
sourceRuntime?: string;
541+
schemaVersion?: number;
542+
}> {
493543
const res = await fetch(`${this.config.baseUrl}/api/agents/runtime/memory`, {
494544
headers: this.runtimeHeaders,
495545
});
@@ -509,6 +559,58 @@ export class CommonlyClient {
509559
if (!res.ok) throw new Error(`Failed to write agent memory: ${res.status}`);
510560
}
511561

562+
/**
563+
* Patch or replace typed agent-memory sections. Cycles are only accepted as
564+
* an append payload, matching the kernel's append-only contract.
565+
*/
566+
async syncAgentMemory(
567+
sections: AgentMemorySyncSections,
568+
options: { mode: "full" | "patch"; sourceRuntime?: string },
569+
): Promise<{
570+
ok: true;
571+
deduped?: boolean;
572+
schemaVersion?: number;
573+
version?: number;
574+
cyclesAppended?: boolean;
575+
truncated?: boolean;
576+
storedChars?: number;
577+
submittedChars?: number;
578+
evicted?: boolean;
579+
retainedEntries?: number;
580+
entryCap?: number;
581+
}> {
582+
const res = await fetch(`${this.config.baseUrl}/api/agents/runtime/memory/sync`, {
583+
method: "POST",
584+
headers: this.runtimeHeaders,
585+
body: JSON.stringify({
586+
sections,
587+
mode: options.mode,
588+
...(options.sourceRuntime !== undefined ? { sourceRuntime: options.sourceRuntime } : {}),
589+
}),
590+
});
591+
if (!res.ok) {
592+
const text = await res.text().catch(() => "");
593+
throw new Error(`Failed to sync agent memory: ${res.status} ${text}`);
594+
}
595+
return res.json();
596+
}
597+
598+
/**
599+
* Fetch an attachment with the agent runtime token. The uploads route uses
600+
* the same pod-membership ACL as agent writes; callers receive bytes only
601+
* after that authorization succeeds.
602+
*/
603+
async readAttachment(fileName: string): Promise<Buffer> {
604+
const res = await fetch(`${this.config.baseUrl}/api/uploads/${encodeURIComponent(fileName)}`, {
605+
headers: this.runtimeHeaders,
606+
});
607+
if (!res.ok) {
608+
const text = await res.text().catch(() => "");
609+
throw new Error(`Failed to read attachment: ${res.status} ${text}`);
610+
}
611+
return Buffer.from(await res.arrayBuffer());
612+
}
613+
512614
/**
513615
* Self-install this agent into an agent-owned pod
514616
*/
@@ -528,6 +630,37 @@ export class CommonlyClient {
528630
return res.json();
529631
}
530632

633+
/**
634+
* Open or retrieve a 1:1 agent DM. The server enforces that both agents
635+
* already share a pod before allowing the private room.
636+
*/
637+
async openAgentDm(
638+
target: { agentName: string; instanceId?: string },
639+
originPodId?: string,
640+
): Promise<{
641+
room: { _id: string; name?: string; type?: string; members?: unknown[] };
642+
autoJoined: boolean;
643+
}> {
644+
const body: Record<string, unknown> = {
645+
target: {
646+
agentName: target.agentName,
647+
...(target.instanceId ? { instanceId: target.instanceId } : {}),
648+
},
649+
};
650+
if (originPodId) body.originPodId = originPodId;
651+
652+
const res = await fetch(`${this.config.baseUrl}/api/agents/runtime/agent-dm`, {
653+
method: "POST",
654+
headers: this.runtimeHeaders,
655+
body: JSON.stringify(body),
656+
});
657+
if (!res.ok) {
658+
const text = await res.text().catch(() => "");
659+
throw new Error(`Failed to open agent DM: ${res.status} ${text}`);
660+
}
661+
return res.json();
662+
}
663+
531664
/**
532665
* List tasks for a pod
533666
*/

0 commit comments

Comments
 (0)