From af4e7051d6645bbf11af8c3a7760da31b4bacee6 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 21:48:29 +0200 Subject: [PATCH 01/11] Add OpenClaw dkg_share tool --- .../adapter-openclaw/src/DkgNodePlugin.ts | 52 ++++++++ .../adapter-openclaw/test/dkg-client.test.ts | 17 +++ packages/adapter-openclaw/test/plugin.test.ts | 126 +++++++++++++++++- packages/cli/skills/dkg-node/SKILL.md | 2 +- 4 files changed, 192 insertions(+), 5 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index b76919d55..e8db378af 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -44,6 +44,7 @@ import type { } from './types.js'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; import { canonicalPathForCompare, defaultStateDirForWorkspace, @@ -2630,6 +2631,22 @@ export class DkgNodePlugin { }, execute: async (_toolCallId, args) => this.handlePublish(args), }, + { + name: 'dkg_share', + description: + 'Direct Shared Working Memory helper: write a concise team-visible note to SWM without staging a ' + + 'Working Memory assertion. Use the assertion create/write/promote flow for durable or canonical work.', + parameters: { + type: 'object', + properties: { + context_graph_id: { type: 'string', description: 'Target context graph ID.' }, + content: { type: 'string', description: 'Concise knowledge to share with the team.' }, + sub_graph_name: { type: 'string', description: 'Optional sub-graph scope for the SWM write.' }, + }, + required: ['context_graph_id', 'content'], + }, + execute: async (_toolCallId, args) => this.handleShare(args), + }, { name: 'dkg_query', description: @@ -3266,6 +3283,41 @@ export class DkgNodePlugin { } } + private async handleShare(args: Record): Promise { + try { + if (args.context_graph !== undefined) { + return this.error('"context_graph" is not a supported parameter on dkg_share. Use "context_graph_id".'); + } + if (args.paranet_id !== undefined || args.paranetId !== undefined) { + return this.error('"paranet_id" is not a supported parameter on dkg_share. Use "context_graph_id".'); + } + const contextGraphId = typeof args.context_graph_id === 'string' ? args.context_graph_id.trim() : ''; + const content = typeof args.content === 'string' ? args.content.trim() : ''; + if (!contextGraphId) return this.error('"context_graph_id" is required.'); + if (!content) return this.error('"content" is required.'); + + const quads = [{ + subject: `urn:openclaw:dkg-share:${randomUUID()}`, + predicate: 'urn:openclaw:dkg-share:content', + object: `"${escapeRdfLiteral(content)}"`, + }]; + const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; + const result = await this.client.share(contextGraphId, quads, { + localOnly: false, + subGraphName, + }); + const shared = result as Record; + return this.json({ + shareOperationId: shared.shareOperationId, + contextGraphId: shared.contextGraphId ?? contextGraphId, + graph: shared.graph, + triplesWritten: shared.triplesWritten ?? quads.length, + }); + } catch (err: any) { + return this.daemonError(err); + } + } + private async handleQuery(args: Record): Promise { try { const sparql = String(args.sparql); diff --git a/packages/adapter-openclaw/test/dkg-client.test.ts b/packages/adapter-openclaw/test/dkg-client.test.ts index 358c945fe..cc5445f4c 100644 --- a/packages/adapter-openclaw/test/dkg-client.test.ts +++ b/packages/adapter-openclaw/test/dkg-client.test.ts @@ -213,6 +213,23 @@ describe('DkgDaemonClient', () => { expect(body.localOnly).toBe(true); }); + it('share should forward explicit team-visible sub-graph writes', async () => { + fetchResponses.push( + new Response(JSON.stringify({ shareOperationId: 'op-2' }), { status: 200 }), + ); + + const quads = [{ subject: 'urn:a', predicate: 'urn:b', object: '"hello"' }]; + await client.share('research-x', quads, { localOnly: false, subGraphName: 'protocols' }); + + const body = JSON.parse(fetchCalls[0][1]?.body as string); + expect(body).toEqual({ + contextGraphId: 'research-x', + quads, + localOnly: false, + subGraphName: 'protocols', + }); + }); + // --------------------------------------------------------------------------- // Working Memory assertion lifecycle // --------------------------------------------------------------------------- diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index 1a3bd2de8..d51983672 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -615,6 +615,7 @@ describe('DkgNodePlugin', () => { expect(toolNames).toContain('dkg_join_request_reject'); expect(toolNames).toContain('dkg_subscribe'); expect(toolNames).toContain('dkg_publish'); + expect(toolNames).toContain('dkg_share'); expect(toolNames).toContain('dkg_query'); expect(toolNames).toContain('dkg_find_agents'); expect(toolNames).toContain('dkg_send_message'); @@ -636,8 +637,8 @@ describe('DkgNodePlugin', () => { expect(toolNames).not.toContain('dkg_paranet_create'); // memory_search added by this feature branch (W2 — agent-callable recall button). expect(toolNames).toContain('memory_search'); - // 28 from main (originals + assertion/subgraph/SWM/CG-registration tools) + 1 memory_search = 29 - expect(registeredTools.length).toBe(29); + // 28 from main (originals + assertion/subgraph/SWM/CG-registration tools) + dkg_share + 1 memory_search = 30 + expect(registeredTools.length).toBe(30); }); it('new dkg_assertion_* and dkg_sub_graph_* tools have the expected schema shape', () => { @@ -683,6 +684,13 @@ describe('DkgNodePlugin', () => { expectRequired('dkg_sub_graph_create', ['context_graph_id', 'sub_graph_name']); expectRequired('dkg_sub_graph_list', ['context_graph_id']); expectRequired('dkg_shared_memory_publish', ['context_graph_id']); + expectRequired('dkg_share', ['context_graph_id', 'content']); + + const shareProps = byName.get('dkg_share')!.parameters.properties; + expect(shareProps).toHaveProperty('sub_graph_name'); + expect(shareProps.sub_graph_name.type).toBe('string'); + expect(shareProps).not.toHaveProperty('context_graph'); + expect(shareProps).not.toHaveProperty('paranet_id'); // dkg_shared_memory_publish must declare `sub_graph_name` so agents that // create/write/promote into a sub-graph can publish the promoted data @@ -1115,6 +1123,116 @@ describe('DkgNodePlugin', () => { expect(init.body).toBeUndefined(); }); + it('dkg_share writes content to team-visible SWM and returns V10-shaped JSON', async () => { + const { fetchMock, byName } = setupPluginWithFetch({ + shareOperationId: 'op-1', + workspaceOperationId: 'legacy-op-1', + contextGraphId: 'ctx', + paranetId: 'ctx', + graph: 'did:dkg:context-graph:ctx/shared-memory/sub/protocols', + triplesWritten: 1, + }); + + const result = await byName.get('dkg_share')!.execute('tc', { + context_graph_id: 'ctx', + content: 'Alpha\nBeta', + sub_graph_name: 'protocols', + }); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('http://localhost:9200/api/shared-memory/write'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + contextGraphId: 'ctx', + quads: [ + { + subject: expect.stringMatching(/^urn:openclaw:dkg-share:[0-9a-f-]+$/), + predicate: 'urn:openclaw:dkg-share:content', + object: '"Alpha\\nBeta"', + }, + ], + localOnly: false, + subGraphName: 'protocols', + }); + expect(body).not.toHaveProperty('context_graph'); + expect(body).not.toHaveProperty('paranetId'); + + const shaped = JSON.parse(result.content[0].text); + expect(shaped).toEqual({ + shareOperationId: 'op-1', + contextGraphId: 'ctx', + graph: 'did:dkg:context-graph:ctx/shared-memory/sub/protocols', + triplesWritten: 1, + }); + expect(shaped).not.toHaveProperty('workspaceOperationId'); + expect(shaped).not.toHaveProperty('paranetId'); + expect(result.details).toEqual(shaped); + }); + + it('dkg_share validates required inputs and rejects legacy aliases locally', async () => { + const { fetchMock, byName } = setupPluginWithFetch({}); + + const missingContext = await byName.get('dkg_share')!.execute('tc', { + content: 'alpha', + }); + expect(missingContext.content[0].text).toContain('context_graph_id'); + + const missingContent = await byName.get('dkg_share')!.execute('tc', { + context_graph_id: 'ctx', + content: ' ', + }); + expect(missingContent.content[0].text).toContain('content'); + + const legacyContext = await byName.get('dkg_share')!.execute('tc', { + context_graph_id: 'ctx', + context_graph: 'legacy', + content: 'alpha', + }); + expect(legacyContext.content[0].text).toContain('context_graph'); + expect(legacyContext.content[0].text).toContain('context_graph_id'); + + const legacyParanet = await byName.get('dkg_share')!.execute('tc', { + context_graph_id: 'ctx', + paranet_id: 'legacy', + content: 'alpha', + }); + expect(legacyParanet.content[0].text).toContain('paranet_id'); + expect(legacyParanet.content[0].text).toContain('context_graph_id'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('dkg_share shapes daemon errors instead of throwing', async () => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ error: 'share failed' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const plugin = new DkgNodePlugin({ daemonUrl: 'http://localhost:9200' }); + const tools: OpenClawTool[] = []; + plugin.register({ + config: {}, + registerTool: (t) => tools.push(t), + registerHook: () => {}, + on: () => {}, + logger: {}, + }); + const byName = new Map(tools.map((t) => [t.name, t] as const)); + + const result = await byName.get('dkg_share')!.execute('tc', { + context_graph_id: 'ctx', + content: 'alpha', + }); + const shaped = JSON.parse(result.content[0].text); + expect(shaped.error).toContain('/api/shared-memory/write'); + expect(shaped.error).toContain('share failed'); + expect(result.details).toEqual(shaped); + }); + it('dkg_shared_memory_publish forwards snake_case → camelCase body with selection="all" when omitted', async () => { const { fetchMock, byName } = setupPluginWithFetch({ kcId: 'kc-1', status: 'ok', kas: [] }); await byName.get('dkg_shared_memory_publish')!.execute('tc', { context_graph_id: 'ctx' }); @@ -1671,7 +1789,7 @@ describe('DkgNodePlugin', () => { // needs it. // --------------------------------------------------------------------------- - it('dkg_subscribe / dkg_publish / dkg_query do not advertise or honor the v9 paranet_id alias', () => { + it('dkg_subscribe / dkg_publish / dkg_share / dkg_query do not advertise or honor the v9 paranet_id alias', () => { const plugin = new DkgNodePlugin(); const tools: OpenClawTool[] = []; plugin.register({ @@ -1682,7 +1800,7 @@ describe('DkgNodePlugin', () => { logger: {}, }); const byName = new Map(tools.map((t) => [t.name, t] as const)); - for (const name of ['dkg_subscribe', 'dkg_publish', 'dkg_query'] as const) { + for (const name of ['dkg_subscribe', 'dkg_publish', 'dkg_share', 'dkg_query'] as const) { const props = byName.get(name)!.parameters.properties; expect(props).not.toHaveProperty('paranet_id'); } diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index 77b250519..ea123d28d 100644 --- a/packages/cli/skills/dkg-node/SKILL.md +++ b/packages/cli/skills/dkg-node/SKILL.md @@ -129,7 +129,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_assertion_history` | `GET /api/assertion/{name}/history` | Read an assertion's lifecycle descriptor | | `dkg_publish` | `POST /api/shared-memory/write` + `POST /api/shared-memory/publish` | **Two-call helper**: first writes supplied quads to SWM via `/write`, then publishes all SWM → VM (TRAC). Calling only the `/publish` route skips the write — if dropping to raw HTTP, use both calls in order | | `dkg_shared_memory_publish` | `POST /api/shared-memory/publish` | **Canonical finalizer** after `dkg_assertion_promote`: publish SWM → VM, no fresh quads | -| `dkg_share` | `POST /api/shared-memory/write` | Directly write concise team-visible knowledge to SWM without staging a WM assertion. Prefer the WM assertion → promote flow for durable/canonical work. Currently Hermes exposes this wrapper; OpenClaw parity is tracked in OriginTrail/dkg#382 | +| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. Prefer the WM assertion → promote flow for durable/canonical work | | `dkg_sub_graph_create` | `POST /api/sub-graph/create` | Register a sub-graph inside a CG | | `dkg_sub_graph_list` | `GET /api/sub-graph/list` | List sub-graphs in a CG | | `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer — when `view` is set, `context_graph_id` is required; for WM reads, optional `agent_address` targets another agent's WM (defaults to this node). Omit `view` for a legacy cross-graph data-path query. | From 7878360583eff54f224525597ef5493b042d4040 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 21:59:36 +0200 Subject: [PATCH 02/11] Address OpenClaw dkg_share review feedback --- packages/adapter-openclaw/src/DkgNodePlugin.ts | 13 ++++++------- packages/adapter-openclaw/src/dkg-client.ts | 9 ++++++++- packages/adapter-openclaw/test/plugin.test.ts | 10 +++++----- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index e8db378af..abcfacca0 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -3292,9 +3292,9 @@ export class DkgNodePlugin { return this.error('"paranet_id" is not a supported parameter on dkg_share. Use "context_graph_id".'); } const contextGraphId = typeof args.context_graph_id === 'string' ? args.context_graph_id.trim() : ''; - const content = typeof args.content === 'string' ? args.content.trim() : ''; + const content = typeof args.content === 'string' ? args.content : ''; if (!contextGraphId) return this.error('"context_graph_id" is required.'); - if (!content) return this.error('"content" is required.'); + if (!content.trim()) return this.error('"content" is required.'); const quads = [{ subject: `urn:openclaw:dkg-share:${randomUUID()}`, @@ -3306,12 +3306,11 @@ export class DkgNodePlugin { localOnly: false, subGraphName, }); - const shared = result as Record; return this.json({ - shareOperationId: shared.shareOperationId, - contextGraphId: shared.contextGraphId ?? contextGraphId, - graph: shared.graph, - triplesWritten: shared.triplesWritten ?? quads.length, + shareOperationId: result.shareOperationId, + contextGraphId: result.contextGraphId ?? contextGraphId, + graph: result.graph, + triplesWritten: result.triplesWritten ?? quads.length, }); } catch (err: any) { return this.daemonError(err); diff --git a/packages/adapter-openclaw/src/dkg-client.ts b/packages/adapter-openclaw/src/dkg-client.ts index 56471fadd..2fea50fb8 100644 --- a/packages/adapter-openclaw/src/dkg-client.ts +++ b/packages/adapter-openclaw/src/dkg-client.ts @@ -28,6 +28,13 @@ export interface DkgClientOptions { timeoutMs?: number; } +export interface SharedMemoryWriteResult { + shareOperationId: string; + contextGraphId?: string; + graph?: string; + triplesWritten?: number; +} + export interface OpenClawAttachmentRef { assertionUri: string; fileHash: string; @@ -237,7 +244,7 @@ export class DkgDaemonClient { contextGraphId: string, quads: Array<{ subject: string; predicate: string; object: string; graph?: string }>, opts?: { localOnly?: boolean; subGraphName?: string }, - ): Promise<{ shareOperationId: string }> { + ): Promise { return this.post('/api/shared-memory/write', { contextGraphId, quads, diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index d51983672..e865d48e8 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { homedir, tmpdir } from 'os'; import * as fs from 'fs'; import * as path from 'path'; -import { toEip55Checksum } from '@origintrail-official/dkg-core'; +import { contextGraphSharedMemoryUri, toEip55Checksum } from '@origintrail-official/dkg-core'; import { DkgNodePlugin } from '../src/DkgNodePlugin.js'; import { DkgChannelPlugin } from '../src/DkgChannelPlugin.js'; import { ChatTurnWriter } from '../src/ChatTurnWriter.js'; @@ -1129,13 +1129,13 @@ describe('DkgNodePlugin', () => { workspaceOperationId: 'legacy-op-1', contextGraphId: 'ctx', paranetId: 'ctx', - graph: 'did:dkg:context-graph:ctx/shared-memory/sub/protocols', + graph: contextGraphSharedMemoryUri('ctx', 'protocols'), triplesWritten: 1, }); const result = await byName.get('dkg_share')!.execute('tc', { context_graph_id: 'ctx', - content: 'Alpha\nBeta', + content: ' Alpha\nBeta ', sub_graph_name: 'protocols', }); @@ -1149,7 +1149,7 @@ describe('DkgNodePlugin', () => { { subject: expect.stringMatching(/^urn:openclaw:dkg-share:[0-9a-f-]+$/), predicate: 'urn:openclaw:dkg-share:content', - object: '"Alpha\\nBeta"', + object: '" Alpha\\nBeta "', }, ], localOnly: false, @@ -1162,7 +1162,7 @@ describe('DkgNodePlugin', () => { expect(shaped).toEqual({ shareOperationId: 'op-1', contextGraphId: 'ctx', - graph: 'did:dkg:context-graph:ctx/shared-memory/sub/protocols', + graph: contextGraphSharedMemoryUri('ctx', 'protocols'), triplesWritten: 1, }); expect(shaped).not.toHaveProperty('workspaceOperationId'); From 77c9406c09855724eb2d33a594123d73fcf66630 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 22:11:15 +0200 Subject: [PATCH 03/11] Return dkg_share root entity --- packages/adapter-openclaw/src/DkgNodePlugin.ts | 4 +++- packages/adapter-openclaw/test/plugin.test.ts | 9 ++++++--- packages/cli/skills/dkg-node/SKILL.md | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index abcfacca0..a52afd45a 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -3296,8 +3296,9 @@ export class DkgNodePlugin { if (!contextGraphId) return this.error('"context_graph_id" is required.'); if (!content.trim()) return this.error('"content" is required.'); + const rootEntity = `urn:openclaw:dkg-share:${randomUUID()}`; const quads = [{ - subject: `urn:openclaw:dkg-share:${randomUUID()}`, + subject: rootEntity, predicate: 'urn:openclaw:dkg-share:content', object: `"${escapeRdfLiteral(content)}"`, }]; @@ -3310,6 +3311,7 @@ export class DkgNodePlugin { shareOperationId: result.shareOperationId, contextGraphId: result.contextGraphId ?? contextGraphId, graph: result.graph, + rootEntity, triplesWritten: result.triplesWritten ?? quads.length, }); } catch (err: any) { diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index e865d48e8..d3fb8ec06 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -637,8 +637,8 @@ describe('DkgNodePlugin', () => { expect(toolNames).not.toContain('dkg_paranet_create'); // memory_search added by this feature branch (W2 — agent-callable recall button). expect(toolNames).toContain('memory_search'); - // 28 from main (originals + assertion/subgraph/SWM/CG-registration tools) + dkg_share + 1 memory_search = 30 - expect(registeredTools.length).toBe(30); + // Tool names should stay unique even as the registry grows. + expect(new Set(toolNames).size).toBe(toolNames.length); }); it('new dkg_assertion_* and dkg_sub_graph_* tools have the expected schema shape', () => { @@ -1143,11 +1143,12 @@ describe('DkgNodePlugin', () => { expect(url).toBe('http://localhost:9200/api/shared-memory/write'); expect(init.method).toBe('POST'); const body = JSON.parse(init.body as string); + const rootEntity = body.quads[0].subject; expect(body).toEqual({ contextGraphId: 'ctx', quads: [ { - subject: expect.stringMatching(/^urn:openclaw:dkg-share:[0-9a-f-]+$/), + subject: rootEntity, predicate: 'urn:openclaw:dkg-share:content', object: '" Alpha\\nBeta "', }, @@ -1163,8 +1164,10 @@ describe('DkgNodePlugin', () => { shareOperationId: 'op-1', contextGraphId: 'ctx', graph: contextGraphSharedMemoryUri('ctx', 'protocols'), + rootEntity, triplesWritten: 1, }); + expect(shaped.rootEntity).toMatch(/^urn:openclaw:dkg-share:[0-9a-f-]+$/); expect(shaped).not.toHaveProperty('workspaceOperationId'); expect(shaped).not.toHaveProperty('paranetId'); expect(result.details).toEqual(shaped); diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index ea123d28d..2d9f9fd3f 100644 --- a/packages/cli/skills/dkg-node/SKILL.md +++ b/packages/cli/skills/dkg-node/SKILL.md @@ -129,7 +129,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_assertion_history` | `GET /api/assertion/{name}/history` | Read an assertion's lifecycle descriptor | | `dkg_publish` | `POST /api/shared-memory/write` + `POST /api/shared-memory/publish` | **Two-call helper**: first writes supplied quads to SWM via `/write`, then publishes all SWM → VM (TRAC). Calling only the `/publish` route skips the write — if dropping to raw HTTP, use both calls in order | | `dkg_shared_memory_publish` | `POST /api/shared-memory/publish` | **Canonical finalizer** after `dkg_assertion_promote`: publish SWM → VM, no fresh quads | -| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. Prefer the WM assertion → promote flow for durable/canonical work | +| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. Returns `rootEntity` for targeted follow-up query or publish. Prefer the WM assertion → promote flow for durable/canonical work | | `dkg_sub_graph_create` | `POST /api/sub-graph/create` | Register a sub-graph inside a CG | | `dkg_sub_graph_list` | `GET /api/sub-graph/list` | List sub-graphs in a CG | | `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer — when `view` is set, `context_graph_id` is required; for WM reads, optional `agent_address` targets another agent's WM (defaults to this node). Omit `view` for a legacy cross-graph data-path query. | @@ -138,7 +138,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_read_messages` | `GET /api/messages` | Read inbound messages | | `dkg_invoke_skill` | `POST /api/invoke-skill` | Call another agent's skill (best-effort P2P) | -P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking. +P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; keep its returned `rootEntity` when you need a targeted follow-up query or `dkg_shared_memory_publish({ root_entities: [...] })`. ### HTTP-only operations (no tool wrapper) From 52f23c21125c648b560fdde52e9a14d4d5b2c6a6 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 22:17:15 +0200 Subject: [PATCH 04/11] Tighten dkg_share docs and registry test --- packages/adapter-openclaw/test/plugin.test.ts | 33 +++++++++++++++++++ packages/cli/skills/dkg-node/SKILL.md | 4 +-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index d3fb8ec06..0bbb129be 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -637,6 +637,39 @@ describe('DkgNodePlugin', () => { expect(toolNames).not.toContain('dkg_paranet_create'); // memory_search added by this feature branch (W2 — agent-callable recall button). expect(toolNames).toContain('memory_search'); + const expectedToolNames = [ + 'dkg_status', + 'dkg_wallet_balances', + 'dkg_list_context_graphs', + 'dkg_context_graph_create', + 'dkg_context_graph_invite', + 'dkg_participant_add', + 'dkg_participant_remove', + 'dkg_participant_list', + 'dkg_join_request_list', + 'dkg_join_request_approve', + 'dkg_join_request_reject', + 'dkg_subscribe', + 'dkg_publish', + 'dkg_share', + 'dkg_query', + 'dkg_find_agents', + 'dkg_send_message', + 'dkg_read_messages', + 'dkg_invoke_skill', + 'dkg_assertion_create', + 'dkg_assertion_write', + 'dkg_assertion_promote', + 'dkg_assertion_discard', + 'dkg_assertion_import_file', + 'dkg_assertion_query', + 'dkg_assertion_history', + 'dkg_sub_graph_create', + 'dkg_sub_graph_list', + 'dkg_shared_memory_publish', + 'memory_search', + ]; + expect(new Set(toolNames)).toEqual(new Set(expectedToolNames)); // Tool names should stay unique even as the registry grows. expect(new Set(toolNames).size).toBe(toolNames.length); }); diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index 2d9f9fd3f..cb8a2f3e9 100644 --- a/packages/cli/skills/dkg-node/SKILL.md +++ b/packages/cli/skills/dkg-node/SKILL.md @@ -129,7 +129,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_assertion_history` | `GET /api/assertion/{name}/history` | Read an assertion's lifecycle descriptor | | `dkg_publish` | `POST /api/shared-memory/write` + `POST /api/shared-memory/publish` | **Two-call helper**: first writes supplied quads to SWM via `/write`, then publishes all SWM → VM (TRAC). Calling only the `/publish` route skips the write — if dropping to raw HTTP, use both calls in order | | `dkg_shared_memory_publish` | `POST /api/shared-memory/publish` | **Canonical finalizer** after `dkg_assertion_promote`: publish SWM → VM, no fresh quads | -| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. Returns `rootEntity` for targeted follow-up query or publish. Prefer the WM assertion → promote flow for durable/canonical work | +| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. OpenClaw returns `rootEntity` for targeted follow-up query or publish. Prefer the WM assertion → promote flow for durable/canonical work | | `dkg_sub_graph_create` | `POST /api/sub-graph/create` | Register a sub-graph inside a CG | | `dkg_sub_graph_list` | `GET /api/sub-graph/list` | List sub-graphs in a CG | | `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer — when `view` is set, `context_graph_id` is required; for WM reads, optional `agent_address` targets another agent's WM (defaults to this node). Omit `view` for a legacy cross-graph data-path query. | @@ -138,7 +138,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_read_messages` | `GET /api/messages` | Read inbound messages | | `dkg_invoke_skill` | `POST /api/invoke-skill` | Call another agent's skill (best-effort P2P) | -P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; keep its returned `rootEntity` when you need a targeted follow-up query or `dkg_shared_memory_publish({ root_entities: [...] })`. +P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; on OpenClaw, keep the returned `rootEntity` when you need a targeted follow-up query or `dkg_shared_memory_publish({ root_entities: [...] })`. ### HTTP-only operations (no tool wrapper) From 7919a7737a63bb37c6eacd685feb8a3e07b62bfb Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 22:26:59 +0200 Subject: [PATCH 05/11] Refine OpenClaw dkg_share content semantics --- packages/adapter-openclaw/src/DkgNodePlugin.ts | 2 +- packages/adapter-openclaw/test/plugin.test.ts | 2 +- packages/cli/skills/dkg-node/SKILL.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index a52afd45a..6a36f3737 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -3299,7 +3299,7 @@ export class DkgNodePlugin { const rootEntity = `urn:openclaw:dkg-share:${randomUUID()}`; const quads = [{ subject: rootEntity, - predicate: 'urn:openclaw:dkg-share:content', + predicate: 'http://schema.org/text', object: `"${escapeRdfLiteral(content)}"`, }]; const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index 0bbb129be..1a224c778 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -1182,7 +1182,7 @@ describe('DkgNodePlugin', () => { quads: [ { subject: rootEntity, - predicate: 'urn:openclaw:dkg-share:content', + predicate: 'http://schema.org/text', object: '" Alpha\\nBeta "', }, ], diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index cb8a2f3e9..17981a0a1 100644 --- a/packages/cli/skills/dkg-node/SKILL.md +++ b/packages/cli/skills/dkg-node/SKILL.md @@ -129,7 +129,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_assertion_history` | `GET /api/assertion/{name}/history` | Read an assertion's lifecycle descriptor | | `dkg_publish` | `POST /api/shared-memory/write` + `POST /api/shared-memory/publish` | **Two-call helper**: first writes supplied quads to SWM via `/write`, then publishes all SWM → VM (TRAC). Calling only the `/publish` route skips the write — if dropping to raw HTTP, use both calls in order | | `dkg_shared_memory_publish` | `POST /api/shared-memory/publish` | **Canonical finalizer** after `dkg_assertion_promote`: publish SWM → VM, no fresh quads | -| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. OpenClaw returns `rootEntity` for targeted follow-up query or publish. Prefer the WM assertion → promote flow for durable/canonical work | +| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. OpenClaw returns `rootEntity` for targeted follow-up queries; targeted publish by `root_entities` leaves the SWM copy in place. Prefer the WM assertion → promote flow for durable/canonical work | | `dkg_sub_graph_create` | `POST /api/sub-graph/create` | Register a sub-graph inside a CG | | `dkg_sub_graph_list` | `GET /api/sub-graph/list` | List sub-graphs in a CG | | `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer — when `view` is set, `context_graph_id` is required; for WM reads, optional `agent_address` targets another agent's WM (defaults to this node). Omit `view` for a legacy cross-graph data-path query. | @@ -138,7 +138,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_read_messages` | `GET /api/messages` | Read inbound messages | | `dkg_invoke_skill` | `POST /api/invoke-skill` | Call another agent's skill (best-effort P2P) | -P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; on OpenClaw, keep the returned `rootEntity` when you need a targeted follow-up query or `dkg_shared_memory_publish({ root_entities: [...] })`. +P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; on OpenClaw, keep the returned `rootEntity` when you need a targeted follow-up query. If you pass it to `dkg_shared_memory_publish({ root_entities: [...] })`, that selected publish does not clear the SWM copy, so a later full publish can include the note again. ### HTTP-only operations (no tool wrapper) From 74690cd8b5ee33e61b2aeeac77a56d99131b6c40 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 22:33:17 +0200 Subject: [PATCH 06/11] Make OpenClaw dkg_share retry-safe --- packages/adapter-openclaw/src/DkgNodePlugin.ts | 13 ++++++++++--- packages/adapter-openclaw/test/plugin.test.ts | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index 6a36f3737..b55509ab4 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -44,7 +44,7 @@ import type { } from './types.js'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; import { canonicalPathForCompare, defaultStateDirForWorkspace, @@ -3296,13 +3296,20 @@ export class DkgNodePlugin { if (!contextGraphId) return this.error('"context_graph_id" is required.'); if (!content.trim()) return this.error('"content" is required.'); - const rootEntity = `urn:openclaw:dkg-share:${randomUUID()}`; + const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; + const rootEntityHash = createHash('sha256') + .update(contextGraphId) + .update('\0') + .update(subGraphName ?? '') + .update('\0') + .update(content) + .digest('hex'); + const rootEntity = `urn:openclaw:dkg-share:${rootEntityHash}`; const quads = [{ subject: rootEntity, predicate: 'http://schema.org/text', object: `"${escapeRdfLiteral(content)}"`, }]; - const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; const result = await this.client.share(contextGraphId, quads, { localOnly: false, subGraphName, diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index 1a224c778..39700db3c 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -669,7 +669,7 @@ describe('DkgNodePlugin', () => { 'dkg_shared_memory_publish', 'memory_search', ]; - expect(new Set(toolNames)).toEqual(new Set(expectedToolNames)); + expect(toolNames).toEqual(expect.arrayContaining(expectedToolNames)); // Tool names should stay unique even as the registry grows. expect(new Set(toolNames).size).toBe(toolNames.length); }); @@ -1200,10 +1200,21 @@ describe('DkgNodePlugin', () => { rootEntity, triplesWritten: 1, }); - expect(shaped.rootEntity).toMatch(/^urn:openclaw:dkg-share:[0-9a-f-]+$/); + expect(shaped.rootEntity).toMatch(/^urn:openclaw:dkg-share:[0-9a-f]{64}$/); expect(shaped).not.toHaveProperty('workspaceOperationId'); expect(shaped).not.toHaveProperty('paranetId'); expect(result.details).toEqual(shaped); + + const retry = await byName.get('dkg_share')!.execute('tc-retry', { + context_graph_id: 'ctx', + content: ' Alpha\nBeta ', + sub_graph_name: 'protocols', + }); + const [, retryInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + const retryBody = JSON.parse(retryInit.body as string); + const retryShaped = JSON.parse(retry.content[0].text); + expect(retryBody.quads[0].subject).toBe(rootEntity); + expect(retryShaped.rootEntity).toBe(rootEntity); }); it('dkg_share validates required inputs and rejects legacy aliases locally', async () => { From da69e147f6f667d83e5e1437c650e6be86b8dee8 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 22:41:10 +0200 Subject: [PATCH 07/11] Include writer identity in dkg_share roots --- packages/adapter-openclaw/src/DkgNodePlugin.ts | 11 +++++++++++ packages/adapter-openclaw/test/plugin.test.ts | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index b55509ab4..d423e103e 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -3297,7 +3297,18 @@ export class DkgNodePlugin { if (!content.trim()) return this.error('"content" is required.'); const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; + if (this.nodePeerId === undefined) { + await this.ensureNodePeerId().catch(() => {}); + } + const writerIdentity = this.nodePeerId; + if (!writerIdentity) { + return this.error( + '"dkg_share" requires a node peer ID. Retry once the daemon status probe is available.', + ); + } const rootEntityHash = createHash('sha256') + .update(writerIdentity) + .update('\0') .update(contextGraphId) .update('\0') .update(subGraphName ?? '') diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index 39700db3c..9d0844d39 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -812,6 +812,7 @@ describe('DkgNodePlugin', () => { on: () => {}, logger: {}, }); + (plugin as any).nodePeerId = '12D3KooWShareWriterOne'; const byName = new Map(tools.map((t) => [t.name, t] as const)); return { fetchMock, plugin, byName }; }; @@ -1157,7 +1158,7 @@ describe('DkgNodePlugin', () => { }); it('dkg_share writes content to team-visible SWM and returns V10-shaped JSON', async () => { - const { fetchMock, byName } = setupPluginWithFetch({ + const { fetchMock, plugin, byName } = setupPluginWithFetch({ shareOperationId: 'op-1', workspaceOperationId: 'legacy-op-1', contextGraphId: 'ctx', @@ -1205,6 +1206,7 @@ describe('DkgNodePlugin', () => { expect(shaped).not.toHaveProperty('paranetId'); expect(result.details).toEqual(shaped); + (plugin as any).nodeAgentAddress = '0x0000000000000000000000000000000000000001'; const retry = await byName.get('dkg_share')!.execute('tc-retry', { context_graph_id: 'ctx', content: ' Alpha\nBeta ', @@ -1215,6 +1217,18 @@ describe('DkgNodePlugin', () => { const retryShaped = JSON.parse(retry.content[0].text); expect(retryBody.quads[0].subject).toBe(rootEntity); expect(retryShaped.rootEntity).toBe(rootEntity); + + (plugin as any).nodePeerId = '12D3KooWShareWriterTwo'; + const otherWriter = await byName.get('dkg_share')!.execute('tc-other-writer', { + context_graph_id: 'ctx', + content: ' Alpha\nBeta ', + sub_graph_name: 'protocols', + }); + const [, otherWriterInit] = fetchMock.mock.calls[2] as [string, RequestInit]; + const otherWriterBody = JSON.parse(otherWriterInit.body as string); + const otherWriterShaped = JSON.parse(otherWriter.content[0].text); + expect(otherWriterBody.quads[0].subject).not.toBe(rootEntity); + expect(otherWriterShaped.rootEntity).toBe(otherWriterBody.quads[0].subject); }); it('dkg_share validates required inputs and rejects legacy aliases locally', async () => { @@ -1268,6 +1282,7 @@ describe('DkgNodePlugin', () => { on: () => {}, logger: {}, }); + (plugin as any).nodePeerId = '12D3KooWShareWriterOne'; const byName = new Map(tools.map((t) => [t.name, t] as const)); const result = await byName.get('dkg_share')!.execute('tc', { From d073842197d4c32c29ec062d1637814f9a51b6a0 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 22:47:10 +0200 Subject: [PATCH 08/11] Harden dkg_share startup identity handling --- .../adapter-openclaw/src/DkgNodePlugin.ts | 21 ++++++++------- packages/adapter-openclaw/test/plugin.test.ts | 27 +++++++++++++++++++ packages/cli/skills/dkg-node/SKILL.md | 4 +-- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index d423e103e..856c3d41c 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -44,7 +44,7 @@ import type { } from './types.js'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { canonicalPathForCompare, defaultStateDirForWorkspace, @@ -290,6 +290,8 @@ export class DkgNodePlugin { * stampede `/api/status`. Null when no probe is running. Codex Bug B9. */ private peerIdProbeInFlight: Promise | null = null; + private shareWriterFallbackId = randomUUID(); + private shareWriterIdentity: string | undefined; /** * Node agent address returned by `/api/agent/identity`. The daemon resolves * the adapter's node-level Bearer token to its default agent address, which @@ -457,6 +459,8 @@ export class DkgNodePlugin { private resetDaemonScopedCachesForClientChange(): void { this.nodePeerId = undefined; this.peerIdProbeInFlight = null; + this.shareWriterFallbackId = randomUUID(); + this.shareWriterIdentity = undefined; this.nodeAgentAddress = undefined; this.agentAddressProbeInFlight = null; this.availableContextGraphCache = []; @@ -3297,17 +3301,14 @@ export class DkgNodePlugin { if (!content.trim()) return this.error('"content" is required.'); const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; - if (this.nodePeerId === undefined) { - await this.ensureNodePeerId().catch(() => {}); - } - const writerIdentity = this.nodePeerId; - if (!writerIdentity) { - return this.error( - '"dkg_share" requires a node peer ID. Retry once the daemon status probe is available.', - ); + if (!this.shareWriterIdentity) { + if (this.nodePeerId === undefined) { + await this.ensureNodePeerId().catch(() => {}); + } + this.shareWriterIdentity = this.nodePeerId ?? `local:${this.shareWriterFallbackId}`; } const rootEntityHash = createHash('sha256') - .update(writerIdentity) + .update(this.shareWriterIdentity) .update('\0') .update(contextGraphId) .update('\0') diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index 9d0844d39..c78cae8aa 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -1219,6 +1219,7 @@ describe('DkgNodePlugin', () => { expect(retryShaped.rootEntity).toBe(rootEntity); (plugin as any).nodePeerId = '12D3KooWShareWriterTwo'; + (plugin as any).shareWriterIdentity = undefined; const otherWriter = await byName.get('dkg_share')!.execute('tc-other-writer', { context_graph_id: 'ctx', content: ' Alpha\nBeta ', @@ -1229,6 +1230,32 @@ describe('DkgNodePlugin', () => { const otherWriterShaped = JSON.parse(otherWriter.content[0].text); expect(otherWriterBody.quads[0].subject).not.toBe(rootEntity); expect(otherWriterShaped.rootEntity).toBe(otherWriterBody.quads[0].subject); + + (plugin as any).nodePeerId = undefined; + (plugin as any).shareWriterIdentity = undefined; + (plugin as any).shareWriterFallbackId = 'fallback-writer'; + (plugin as any).ensureNodePeerId = vi.fn().mockResolvedValue(undefined); + const startup = await byName.get('dkg_share')!.execute('tc-startup', { + context_graph_id: 'ctx', + content: ' Alpha\nBeta ', + sub_graph_name: 'protocols', + }); + const [, startupInit] = fetchMock.mock.calls[3] as [string, RequestInit]; + const startupBody = JSON.parse(startupInit.body as string); + const startupShaped = JSON.parse(startup.content[0].text); + expect(startupShaped.rootEntity).toBe(startupBody.quads[0].subject); + + (plugin as any).nodePeerId = '12D3KooWShareWriterLate'; + const startupRetry = await byName.get('dkg_share')!.execute('tc-startup-retry', { + context_graph_id: 'ctx', + content: ' Alpha\nBeta ', + sub_graph_name: 'protocols', + }); + const [, startupRetryInit] = fetchMock.mock.calls[4] as [string, RequestInit]; + const startupRetryBody = JSON.parse(startupRetryInit.body as string); + const startupRetryShaped = JSON.parse(startupRetry.content[0].text); + expect(startupRetryBody.quads[0].subject).toBe(startupBody.quads[0].subject); + expect(startupRetryShaped.rootEntity).toBe(startupShaped.rootEntity); }); it('dkg_share validates required inputs and rejects legacy aliases locally', async () => { diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index 17981a0a1..4e477c0f9 100644 --- a/packages/cli/skills/dkg-node/SKILL.md +++ b/packages/cli/skills/dkg-node/SKILL.md @@ -129,7 +129,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_assertion_history` | `GET /api/assertion/{name}/history` | Read an assertion's lifecycle descriptor | | `dkg_publish` | `POST /api/shared-memory/write` + `POST /api/shared-memory/publish` | **Two-call helper**: first writes supplied quads to SWM via `/write`, then publishes all SWM → VM (TRAC). Calling only the `/publish` route skips the write — if dropping to raw HTTP, use both calls in order | | `dkg_shared_memory_publish` | `POST /api/shared-memory/publish` | **Canonical finalizer** after `dkg_assertion_promote`: publish SWM → VM, no fresh quads | -| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. OpenClaw returns `rootEntity` for targeted follow-up queries; targeted publish by `root_entities` leaves the SWM copy in place. Prefer the WM assertion → promote flow for durable/canonical work | +| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. OpenClaw returns `rootEntity` for targeted follow-up queries; targeted publish by `root_entities` clears published roots while leaving unpublished SWM roots untouched. Prefer the WM assertion → promote flow for durable/canonical work | | `dkg_sub_graph_create` | `POST /api/sub-graph/create` | Register a sub-graph inside a CG | | `dkg_sub_graph_list` | `GET /api/sub-graph/list` | List sub-graphs in a CG | | `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer — when `view` is set, `context_graph_id` is required; for WM reads, optional `agent_address` targets another agent's WM (defaults to this node). Omit `view` for a legacy cross-graph data-path query. | @@ -138,7 +138,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_read_messages` | `GET /api/messages` | Read inbound messages | | `dkg_invoke_skill` | `POST /api/invoke-skill` | Call another agent's skill (best-effort P2P) | -P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; on OpenClaw, keep the returned `rootEntity` when you need a targeted follow-up query. If you pass it to `dkg_shared_memory_publish({ root_entities: [...] })`, that selected publish does not clear the SWM copy, so a later full publish can include the note again. +P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; on OpenClaw, keep the returned `rootEntity` when you need a targeted follow-up query. If you pass it to `dkg_shared_memory_publish({ root_entities: [...] })`, that selected publish clears roots it successfully publishes; because partial selection leaves unpublished SWM roots untouched, use a full publish when you want all SWM cleared. ### HTTP-only operations (no tool wrapper) From 8e8319569741dd42bd680eaa65315a0fa8d925a4 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 22:59:14 +0200 Subject: [PATCH 09/11] Use tool call seed for dkg_share roots --- .../adapter-openclaw/src/DkgNodePlugin.ts | 18 ++----- packages/adapter-openclaw/test/plugin.test.ts | 49 +++++++++---------- 2 files changed, 27 insertions(+), 40 deletions(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index 856c3d41c..4ed80bf13 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -290,8 +290,6 @@ export class DkgNodePlugin { * stampede `/api/status`. Null when no probe is running. Codex Bug B9. */ private peerIdProbeInFlight: Promise | null = null; - private shareWriterFallbackId = randomUUID(); - private shareWriterIdentity: string | undefined; /** * Node agent address returned by `/api/agent/identity`. The daemon resolves * the adapter's node-level Bearer token to its default agent address, which @@ -459,8 +457,6 @@ export class DkgNodePlugin { private resetDaemonScopedCachesForClientChange(): void { this.nodePeerId = undefined; this.peerIdProbeInFlight = null; - this.shareWriterFallbackId = randomUUID(); - this.shareWriterIdentity = undefined; this.nodeAgentAddress = undefined; this.agentAddressProbeInFlight = null; this.availableContextGraphCache = []; @@ -2649,7 +2645,7 @@ export class DkgNodePlugin { }, required: ['context_graph_id', 'content'], }, - execute: async (_toolCallId, args) => this.handleShare(args), + execute: async (toolCallId, args) => this.handleShare(toolCallId, args), }, { name: 'dkg_query', @@ -3287,7 +3283,7 @@ export class DkgNodePlugin { } } - private async handleShare(args: Record): Promise { + private async handleShare(toolCallId: string, args: Record): Promise { try { if (args.context_graph !== undefined) { return this.error('"context_graph" is not a supported parameter on dkg_share. Use "context_graph_id".'); @@ -3301,14 +3297,10 @@ export class DkgNodePlugin { if (!content.trim()) return this.error('"content" is required.'); const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; - if (!this.shareWriterIdentity) { - if (this.nodePeerId === undefined) { - await this.ensureNodePeerId().catch(() => {}); - } - this.shareWriterIdentity = this.nodePeerId ?? `local:${this.shareWriterFallbackId}`; - } + const trimmedToolCallId = typeof toolCallId === 'string' ? toolCallId.trim() : ''; + const toolCallSeed = trimmedToolCallId || `generated:${randomUUID()}`; const rootEntityHash = createHash('sha256') - .update(this.shareWriterIdentity) + .update(toolCallSeed) .update('\0') .update(contextGraphId) .update('\0') diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index c78cae8aa..f3bd98664 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -812,7 +812,6 @@ describe('DkgNodePlugin', () => { on: () => {}, logger: {}, }); - (plugin as any).nodePeerId = '12D3KooWShareWriterOne'; const byName = new Map(tools.map((t) => [t.name, t] as const)); return { fetchMock, plugin, byName }; }; @@ -1207,7 +1206,9 @@ describe('DkgNodePlugin', () => { expect(result.details).toEqual(shaped); (plugin as any).nodeAgentAddress = '0x0000000000000000000000000000000000000001'; - const retry = await byName.get('dkg_share')!.execute('tc-retry', { + (plugin as any).nodePeerId = '12D3KooWShareWriterTwo'; + (plugin as any).ensureNodePeerId = vi.fn().mockResolvedValue(undefined); + const retry = await byName.get('dkg_share')!.execute('tc', { context_graph_id: 'ctx', content: ' Alpha\nBeta ', sub_graph_name: 'protocols', @@ -1217,45 +1218,40 @@ describe('DkgNodePlugin', () => { const retryShaped = JSON.parse(retry.content[0].text); expect(retryBody.quads[0].subject).toBe(rootEntity); expect(retryShaped.rootEntity).toBe(rootEntity); + expect((plugin as any).ensureNodePeerId).not.toHaveBeenCalled(); - (plugin as any).nodePeerId = '12D3KooWShareWriterTwo'; - (plugin as any).shareWriterIdentity = undefined; - const otherWriter = await byName.get('dkg_share')!.execute('tc-other-writer', { + const otherShare = await byName.get('dkg_share')!.execute('tc-other-share', { context_graph_id: 'ctx', content: ' Alpha\nBeta ', sub_graph_name: 'protocols', }); - const [, otherWriterInit] = fetchMock.mock.calls[2] as [string, RequestInit]; - const otherWriterBody = JSON.parse(otherWriterInit.body as string); - const otherWriterShaped = JSON.parse(otherWriter.content[0].text); - expect(otherWriterBody.quads[0].subject).not.toBe(rootEntity); - expect(otherWriterShaped.rootEntity).toBe(otherWriterBody.quads[0].subject); + const [, otherShareInit] = fetchMock.mock.calls[2] as [string, RequestInit]; + const otherShareBody = JSON.parse(otherShareInit.body as string); + const otherShareShaped = JSON.parse(otherShare.content[0].text); + expect(otherShareBody.quads[0].subject).not.toBe(rootEntity); + expect(otherShareShaped.rootEntity).toBe(otherShareBody.quads[0].subject); + expect((plugin as any).ensureNodePeerId).not.toHaveBeenCalled(); - (plugin as any).nodePeerId = undefined; - (plugin as any).shareWriterIdentity = undefined; - (plugin as any).shareWriterFallbackId = 'fallback-writer'; - (plugin as any).ensureNodePeerId = vi.fn().mockResolvedValue(undefined); - const startup = await byName.get('dkg_share')!.execute('tc-startup', { + const missingToolCallId = await byName.get('dkg_share')!.execute('', { context_graph_id: 'ctx', content: ' Alpha\nBeta ', sub_graph_name: 'protocols', }); - const [, startupInit] = fetchMock.mock.calls[3] as [string, RequestInit]; - const startupBody = JSON.parse(startupInit.body as string); - const startupShaped = JSON.parse(startup.content[0].text); - expect(startupShaped.rootEntity).toBe(startupBody.quads[0].subject); + const [, missingToolCallIdInit] = fetchMock.mock.calls[3] as [string, RequestInit]; + const missingToolCallIdBody = JSON.parse(missingToolCallIdInit.body as string); + const missingToolCallIdShaped = JSON.parse(missingToolCallId.content[0].text); + expect(missingToolCallIdBody.quads[0].subject).toMatch(/^urn:openclaw:dkg-share:[0-9a-f]{64}$/); + expect(missingToolCallIdShaped.rootEntity).toBe(missingToolCallIdBody.quads[0].subject); - (plugin as any).nodePeerId = '12D3KooWShareWriterLate'; - const startupRetry = await byName.get('dkg_share')!.execute('tc-startup-retry', { + const secondMissingToolCallId = await byName.get('dkg_share')!.execute('', { context_graph_id: 'ctx', content: ' Alpha\nBeta ', sub_graph_name: 'protocols', }); - const [, startupRetryInit] = fetchMock.mock.calls[4] as [string, RequestInit]; - const startupRetryBody = JSON.parse(startupRetryInit.body as string); - const startupRetryShaped = JSON.parse(startupRetry.content[0].text); - expect(startupRetryBody.quads[0].subject).toBe(startupBody.quads[0].subject); - expect(startupRetryShaped.rootEntity).toBe(startupShaped.rootEntity); + const [, secondMissingToolCallIdInit] = fetchMock.mock.calls[4] as [string, RequestInit]; + const secondMissingToolCallIdBody = JSON.parse(secondMissingToolCallIdInit.body as string); + expect(secondMissingToolCallIdBody.quads[0].subject).not.toBe(missingToolCallIdBody.quads[0].subject); + expect((plugin as any).ensureNodePeerId).not.toHaveBeenCalled(); }); it('dkg_share validates required inputs and rejects legacy aliases locally', async () => { @@ -1309,7 +1305,6 @@ describe('DkgNodePlugin', () => { on: () => {}, logger: {}, }); - (plugin as any).nodePeerId = '12D3KooWShareWriterOne'; const byName = new Map(tools.map((t) => [t.name, t] as const)); const result = await byName.get('dkg_share')!.execute('tc', { From e3eae178c9aaff205393678a9f12fa18fcb10a25 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 23:06:33 +0200 Subject: [PATCH 10/11] Validate dkg_share sub graph names --- .../adapter-openclaw/src/DkgNodePlugin.ts | 8 +++- packages/adapter-openclaw/test/plugin.test.ts | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/adapter-openclaw/src/DkgNodePlugin.ts b/packages/adapter-openclaw/src/DkgNodePlugin.ts index 4ed80bf13..eebbed9ba 100644 --- a/packages/adapter-openclaw/src/DkgNodePlugin.ts +++ b/packages/adapter-openclaw/src/DkgNodePlugin.ts @@ -3296,7 +3296,13 @@ export class DkgNodePlugin { if (!contextGraphId) return this.error('"context_graph_id" is required.'); if (!content.trim()) return this.error('"content" is required.'); - const subGraphName = args.sub_graph_name ? String(args.sub_graph_name) : undefined; + let subGraphName: string | undefined; + if (args.sub_graph_name !== undefined) { + if (typeof args.sub_graph_name !== 'string' || !args.sub_graph_name.trim()) { + return this.error('"sub_graph_name" must be a non-empty string when provided.'); + } + subGraphName = args.sub_graph_name.trim(); + } const trimmedToolCallId = typeof toolCallId === 'string' ? toolCallId.trim() : ''; const toolCallSeed = trimmedToolCallId || `generated:${randomUUID()}`; const rootEntityHash = createHash('sha256') diff --git a/packages/adapter-openclaw/test/plugin.test.ts b/packages/adapter-openclaw/test/plugin.test.ts index f3bd98664..925a96002 100644 --- a/packages/adapter-openclaw/test/plugin.test.ts +++ b/packages/adapter-openclaw/test/plugin.test.ts @@ -1254,6 +1254,38 @@ describe('DkgNodePlugin', () => { expect((plugin as any).ensureNodePeerId).not.toHaveBeenCalled(); }); + it('dkg_share omits absent sub_graph_name and trims valid sub_graph_name', async () => { + const { fetchMock, byName } = setupPluginWithFetch({ + shareOperationId: 'op-1', + contextGraphId: 'ctx', + triplesWritten: 1, + }); + + await byName.get('dkg_share')!.execute('tc-omit-subgraph', { + context_graph_id: 'ctx', + content: 'alpha', + }); + const omittedBody = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(omittedBody).not.toHaveProperty('subGraphName'); + + await byName.get('dkg_share')!.execute('tc-trim-subgraph', { + context_graph_id: 'ctx', + content: 'alpha', + sub_graph_name: ' protocols ', + }); + const trimmedBody = JSON.parse(fetchMock.mock.calls[1][1]?.body as string); + expect(trimmedBody.subGraphName).toBe('protocols'); + + await byName.get('dkg_share')!.execute('tc-trim-subgraph', { + context_graph_id: 'ctx', + content: 'alpha', + sub_graph_name: 'protocols', + }); + const canonicalBody = JSON.parse(fetchMock.mock.calls[2][1]?.body as string); + expect(canonicalBody.subGraphName).toBe('protocols'); + expect(canonicalBody.quads[0].subject).toBe(trimmedBody.quads[0].subject); + }); + it('dkg_share validates required inputs and rejects legacy aliases locally', async () => { const { fetchMock, byName } = setupPluginWithFetch({}); @@ -1268,6 +1300,22 @@ describe('DkgNodePlugin', () => { }); expect(missingContent.content[0].text).toContain('content'); + const blankSubGraph = await byName.get('dkg_share')!.execute('tc', { + context_graph_id: 'ctx', + content: 'alpha', + sub_graph_name: ' ', + }); + expect(blankSubGraph.content[0].text).toContain('sub_graph_name'); + expect(blankSubGraph.content[0].text).toMatch(/non-empty|string/); + + const nonStringSubGraph = await byName.get('dkg_share')!.execute('tc', { + context_graph_id: 'ctx', + content: 'alpha', + sub_graph_name: 123, + }); + expect(nonStringSubGraph.content[0].text).toContain('sub_graph_name'); + expect(nonStringSubGraph.content[0].text).toContain('string'); + const legacyContext = await byName.get('dkg_share')!.execute('tc', { context_graph_id: 'ctx', context_graph: 'legacy', From 6a9536939e0f57d0446c46f961ff6a055ef96262 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 4 May 2026 23:17:36 +0200 Subject: [PATCH 11/11] Clarify targeted dkg_share publish cleanup --- packages/cli/skills/dkg-node/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/skills/dkg-node/SKILL.md b/packages/cli/skills/dkg-node/SKILL.md index 4e477c0f9..2d19b7e1b 100644 --- a/packages/cli/skills/dkg-node/SKILL.md +++ b/packages/cli/skills/dkg-node/SKILL.md @@ -129,7 +129,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_assertion_history` | `GET /api/assertion/{name}/history` | Read an assertion's lifecycle descriptor | | `dkg_publish` | `POST /api/shared-memory/write` + `POST /api/shared-memory/publish` | **Two-call helper**: first writes supplied quads to SWM via `/write`, then publishes all SWM → VM (TRAC). Calling only the `/publish` route skips the write — if dropping to raw HTTP, use both calls in order | | `dkg_shared_memory_publish` | `POST /api/shared-memory/publish` | **Canonical finalizer** after `dkg_assertion_promote`: publish SWM → VM, no fresh quads | -| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. OpenClaw returns `rootEntity` for targeted follow-up queries; targeted publish by `root_entities` clears published roots while leaving unpublished SWM roots untouched. Prefer the WM assertion → promote flow for durable/canonical work | +| `dkg_share` | `POST /api/shared-memory/write` | OpenClaw/Hermes direct helper for writing concise team-visible knowledge to SWM without staging a WM assertion. OpenClaw returns `rootEntity` for targeted follow-up queries; targeted publish by `root_entities` drains selected roots after confirmation while preserving unselected SWM roots by default. Prefer the WM assertion → promote flow for durable/canonical work | | `dkg_sub_graph_create` | `POST /api/sub-graph/create` | Register a sub-graph inside a CG | | `dkg_sub_graph_list` | `GET /api/sub-graph/list` | List sub-graphs in a CG | | `dkg_query` | `POST /api/query` | Read-only SPARQL across assertions in a CG. Pass `view` (`working-memory` / `shared-working-memory` / `verified-memory`) to pick the layer — when `view` is set, `context_graph_id` is required; for WM reads, optional `agent_address` targets another agent's WM (defaults to this node). Omit `view` for a legacy cross-graph data-path query. | @@ -138,7 +138,7 @@ Drop to HTTP when the operation isn't in the table — participant self-service | `dkg_read_messages` | `GET /api/messages` | Read inbound messages | | `dkg_invoke_skill` | `POST /api/invoke-skill` | Call another agent's skill (best-effort P2P) | -P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; on OpenClaw, keep the returned `rootEntity` when you need a targeted follow-up query. If you pass it to `dkg_shared_memory_publish({ root_entities: [...] })`, that selected publish clears roots it successfully publishes; because partial selection leaves unpublished SWM roots untouched, use a full publish when you want all SWM cleared. +P2P tools fail gracefully when the peer is offline. `dkg_publish` (fresh quads + write + publish, two HTTP calls) and `dkg_shared_memory_publish` (publish existing SWM, one HTTP call) differ in intent: use the two-call helper for "I have quads, publish now"; use the canonical finalizer as step 4 of the stepwise write → promote → publish flow. `dkg_share` is a direct SWM convenience helper for quick team-visible notes, not a replacement for assertion lifecycle tracking; on OpenClaw, keep the returned `rootEntity` when you need a targeted follow-up query. If you pass it to `dkg_shared_memory_publish({ root_entities: [...] })`, OpenClaw performs a subset publish with `clearAfter=false` by default: roots in the selection are drained from SWM after confirmation, while roots outside the selection remain. Use a full publish when you want all SWM published and cleared; use raw HTTP or client code with `clearAfter: true` only when you intentionally want a selected publish to also clear the unselected remainder. ### HTTP-only operations (no tool wrapper)