@sym-bot/mesh-channel 0.3.20 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sym-mesh-channel",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "Real-time Claude-to-Claude mesh. Agent-to-agent cognitive signals over Bonjour LAN or WebSocket relay.",
5
5
  "author": {
6
6
  "name": "Hongwei Xu",
package/.mcp.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@sym-bot/mesh-channel@0.3.20"
7
+ "@sym-bot/mesh-channel@0.3.22"
8
8
  ],
9
9
  "env": {
10
10
  "SYM_RELAY_URL": "${user_config.relay_url}",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.22
4
+
5
+ ### Added
6
+
7
+ - **Per-project identity via `$CLAUDE_PROJECT_DIR/.sym/node.json`.** A named role agent (e.g. a CTO node `claude-code-mac` on `sym-bot-team`, or `melotune-dev` on `melo-ios`) can now commit `{ "node_name": "...", "group": "..." }` to `.sym/node.json` in its repo, and the plugin reads it on start. This lets the **plugin alone** carry a stable, per-project identity — no parallel `claude-sym-mesh` MCP registration in a project `.mcp.json`, which previously produced a *second* mesh node (the project-scoped server plus the plugin-scoped server are never deduplicated). Because the identity lives in the repo, it survives a plugin reinstall. Precedence is unchanged-and-extended: `SYM_NODE_NAME`/`SYM_GROUP` env still win, then `.sym/node.json`, then the auto `claude-<repo>-<session>` default. A missing or malformed file is ignored (falls back to the auto default) — never a hard fail.
8
+
9
+ ## 0.3.21
10
+
11
+ ### Changed
12
+
13
+ - **`sym_inbox` is now a thin adapter over the SDK primitive `node.inbox()`** (`@sym-bot/sym` ^0.7.11). The pull-based receive buffer + drain cursor moved down into the node, where it belongs alongside `node.remember()` (send) — so the SDK is sufficient for send **and** pull on its own, and the wrapper owns no buffering logic. Behaviour is unchanged (FIFO drain, `peek`, `limit`); `sym_fetch` now also resolves SDK inbox ids (`inNNNN`). The wrapper still applies the peer allowlist + prompt-injection filter on the pull path before anything enters context.
14
+
3
15
  ## 0.3.20
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sym-bot/mesh-channel",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "MCP server — real-time agent-to-agent cognition for Claude Code remote teams via the SYM mesh.",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  ],
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/sdk": "^1.12.1",
25
- "@sym-bot/sym": "^0.7.10",
25
+ "@sym-bot/sym": "^0.7.11",
26
26
  "bonjour-service": "^1.3.0"
27
27
  },
28
28
  "engines": {
package/server.js CHANGED
@@ -232,7 +232,23 @@ function resolveNodeName(base) {
232
232
  }
233
233
  return base;
234
234
  }
235
- const NODE_NAME = resolveNodeName(process.env.SYM_NODE_NAME || defaultNodeName());
235
+ // Per-project identity (v0.3.22): a named role agent (CTO, melotune-dev, …) commits
236
+ // its node name + group to `$CLAUDE_PROJECT_DIR/.sym/node.json`, so the plugin alone
237
+ // carries a stable per-project identity — no parallel MCP registration, and it
238
+ // survives a plugin reinstall because the config lives in the repo, not the plugin.
239
+ // Env (SYM_NODE_NAME/SYM_GROUP) still wins; this only overrides the auto default.
240
+ // Missing/malformed file → {} → auto default; never a hard fail.
241
+ function projectNodeConfig() {
242
+ const fs = require('fs'), path = require('path');
243
+ const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
244
+ try {
245
+ const cfg = JSON.parse(fs.readFileSync(path.join(dir, '.sym', 'node.json'), 'utf8'));
246
+ const clean = (s) => (typeof s === 'string' && s.trim()) ? s.trim() : undefined;
247
+ return { node_name: clean(cfg.node_name), group: clean(cfg.group) };
248
+ } catch { return {}; }
249
+ }
250
+ const PROJECT_CFG = projectNodeConfig();
251
+ const NODE_NAME = resolveNodeName(process.env.SYM_NODE_NAME || PROJECT_CFG.node_name || defaultNodeName());
236
252
 
237
253
  // ── Mesh group (MMP §5.8) ──────────────────────────────────
238
254
  //
@@ -245,7 +261,7 @@ const NODE_NAME = resolveNodeName(process.env.SYM_NODE_NAME || defaultNodeName()
245
261
  function resolveServiceType() {
246
262
  const explicit = process.env.SYM_SERVICE_TYPE;
247
263
  if (explicit) return explicit;
248
- const group = process.env.SYM_GROUP;
264
+ const group = process.env.SYM_GROUP || PROJECT_CFG.group;
249
265
  if (group && group !== 'default') return `_${group}._tcp`;
250
266
  return '_sym._tcp';
251
267
  }
@@ -253,7 +269,7 @@ function resolveServiceType() {
253
269
  // Claude Code restart. Declaring as `let` rather than `const` is the
254
270
  // smallest change that makes hot-swap possible.
255
271
  let SERVICE_TYPE = resolveServiceType();
256
- let GROUP = process.env.SYM_GROUP || (SERVICE_TYPE !== '_sym._tcp'
272
+ let GROUP = process.env.SYM_GROUP || PROJECT_CFG.group || (SERVICE_TYPE !== '_sym._tcp'
257
273
  ? SERVICE_TYPE.replace(/^_/, '').replace(/\._tcp$/, '')
258
274
  : 'default');
259
275
  let RELAY_URL = process.env.SYM_RELAY_URL || null;
@@ -651,6 +667,12 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
651
667
  }
652
668
 
653
669
  case 'sym_fetch': {
670
+ // inNNNN → SDK delivery inbox (pull path); mNNN → channel-push store.
671
+ if (typeof args.msg_id === 'string' && args.msg_id.startsWith('in')) {
672
+ const m = node.inboxGet(args.msg_id);
673
+ if (!m) return { content: [{ type: 'text', text: `Message ${args.msg_id} not found (expired or invalid ID).` }] };
674
+ return { content: [{ type: 'text', text: `[${m.from}] ${new Date(m.receivedAt).toISOString()}\n\n${m.content}` }] };
675
+ }
654
676
  const entry = MESSAGE_STORE.get(args.msg_id);
655
677
  if (!entry) {
656
678
  return { content: [{ type: 'text', text: `Message ${args.msg_id} not found (expired or invalid ID).` }] };
@@ -664,35 +686,34 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
664
686
  }
665
687
 
666
688
  case 'sym_inbox': {
667
- const limit = Math.max(1, Math.min(args.limit || 50, MAX_STORED));
668
- const fresh = [];
669
- for (const [id, entry] of MESSAGE_STORE) {
670
- const seq = parseInt(id.slice(1), 10);
671
- if (seq > inboxCursor) fresh.push({ id, seq, entry });
672
- }
673
- fresh.sort((a, b) => a.seq - b.seq);
674
- // FIFO: drain the OLDEST `limit` first so the cursor advances contiguously
675
- // and a backlog larger than `limit` is never skipped — the rest come on the
676
- // next call. (Newest-first would jump the cursor past un-returned items.)
677
- const slice = fresh.slice(0, limit);
678
- if (!args.peek && slice.length) {
679
- inboxCursor = Math.max(inboxCursor, ...slice.map((i) => i.seq));
680
- }
681
- if (!slice.length) {
689
+ // Thin adapter over the SDK primitive: the node owns the delivery buffer
690
+ // + drain cursor (node.inbox()). This wrapper only formats for display.
691
+ const { messages, remaining } = node.inbox({ peek: !!args.peek, limit: args.limit });
692
+ if (!messages.length) {
682
693
  return { content: [{ type: 'text', text: 'Inbox empty — no new mesh messages since your last check.' }] };
683
694
  }
684
- const more = fresh.length - slice.length;
685
695
  const now = Date.now();
686
- const lines = slice.map(({ id, entry }) => {
687
- const age = Math.round((now - entry.timestamp) / 1000);
688
- const head = entry.header || `[${entry.from}] ${String(entry.content || '').replace(/\s+/g, ' ').slice(0, 80)}`;
689
- return `${head} [${id}] (${age}s ago)`;
690
- });
691
- const moreNote = more > 0 ? ` (+${more} more — call sym_inbox again)` : '';
696
+ const lines = messages.map((m) => {
697
+ if (m.from === NODE_NAME) return null; // never surface our own deliveries
698
+ // The security layer still gates the pull path: peer allowlist +
699
+ // prompt-injection filter run on every message before it enters context.
700
+ if (!isPeerAllowed(m.from)) return null;
701
+ const sec = checkSecurity(m.from, m.fields || {}, m.fields?.payload);
702
+ if (!sec.safe) { securityAudit(sec.reason, m.from, sec.excerpt); return null; }
703
+ const age = Math.round((now - m.receivedAt) / 1000);
704
+ const focus = m.fields?.focus?.text || m.content || '';
705
+ const dirTag = m.directed ? ' →you' : '';
706
+ const memTag = m.directed && m.remixed === false ? ' ·not-stored' : '';
707
+ return `[${m.from}${dirTag}] ${String(focus).replace(/\s+/g, ' ').slice(0, 90)}${memTag} [${m.id}] (${age}s ago)`;
708
+ }).filter(Boolean);
709
+ if (!lines.length) {
710
+ return { content: [{ type: 'text', text: 'Inbox empty — no new mesh messages since your last check.' }] };
711
+ }
712
+ const moreNote = remaining > 0 ? ` (+${remaining} more — call sym_inbox again)` : '';
692
713
  return {
693
714
  content: [{
694
715
  type: 'text',
695
- text: `${slice.length} new mesh message(s)${args.peek ? ' (peek — not drained)' : ''}${moreNote}:\n${lines.join('\n')}\n\nUse sym_fetch <id> for full content; reply via sym_send to=<peer>.`,
716
+ text: `${lines.length} new mesh message(s)${args.peek ? ' (peek — not drained)' : ''}${moreNote}:\n${lines.join('\n')}\n\nUse sym_fetch <id> for full content; reply via sym_send to=<peer>.`,
696
717
  }],
697
718
  };
698
719
  }
@@ -909,9 +930,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
909
930
  // Per COO spec cmb_compact_channel_v0.1.md: push compact headers,
910
931
  // store full content for on-demand sym_fetch retrieval. ~10% token
911
932
  // savings on mesh traffic without context loss.
912
- const MESSAGE_STORE = new Map();
933
+ const MESSAGE_STORE = new Map(); // channel-push surface (mNNN) for sym_fetch when channels are enabled
913
934
  let msgSeq = 0;
914
- let inboxCursor = 0; // highest msgSeq drained by sym_inbox (pull-based receive)
915
935
  const MAX_STORED = 200;
916
936
 
917
937
  function storeMessage(from, content, header) {