Note: Versions 0.3.26 – 0.3.55 were released as git tags without changelog entries. Changelog resumes at 0.3.56 below.
- A rooted deployment keeps its identity, keys, stores and lock inside its own root. Setting
SYM_STATE_DIRalready moved a node's memory; it did not move the tree that holds each node's identity, keypair and single-writer lock, which stayed at~/.sym/nodesfor every deployment on the machine. Two independent deployments on one host — each with its ownSYM_STATE_DIR— therefore shared one identity tree, and the second to start was refused the lock the first held, while appearing healthy in every other respect. The identity tree, the daemon socket and the logs now followSYM_STATE_DIRtoo. Nothing moves for a deployment that sets nothing:~/.symremains the default, in the same place, with the same contents.
-
The runtime is now self-sufficient — everything a node needs to think is in this package. Semantic category encoding and SVAF evaluation, which previously lived in the separate
@sym-bot/corepackage, are part of the open runtime. A node can create, sign, exchange, verify, evaluate, admit and store records with no additional engine installed, which is what makes this package a complete, independently usable implementation of MMP 2.0 rather than the open half of a pair. -
Every release is gated on two stock nodes. Before publishing, the packed tarball is installed into an empty directory and two plain nodes are driven through the entire path — create, sign, exchange, verify, evaluate, admit, store with lineage — and then one is restarted and the record must still be there, with the signed record of why it was admitted. The gate runs against the artifact you install, not the source tree, because those are not the same thing.
- Nothing existing behaves differently. The absorbed code is additive: where both lineages had an implementation, this package keeps its own — measured, not assumed, by the signing and interoperability conformance suites, which decide any disagreement. Existing nodes upgrade with no change to the wire, to record addresses, or to stored data.
-
Reads the published MMP v2.0 record signature suite. A record signed under
mmp-sig-v2.0now verifies against the published preimage, so a v2.0 record from an independent implementation interoperates with this one. Signature verification, the end-to-end category encryption, the handshake proof-of- possession, and the session key schedule all match the published v2.0 spec byte-for-byte. Records signed under the previous suite continue to verify unchanged — this release reads v2.0; it still emits the previous suite, so nothing on the wire changes for peers on earlier versions. Emission moves to v2.0 in a later release, once v2.0 readers are widely deployed. -
Verification receipt for verified-record consumers. After this node verifies a v2.0 record it can emit a compact receipt bound to the exact record bytes, so a downstream consumer can admit the record without re-verifying it and detect any mutation between verification and use.
- Lineage is walked from verified local records only. A record's provenance is resolved by traversing parents this node has actually stored, never a sender-supplied ancestor list — a non-conforming peer can no longer inject an apparent root. Admission freshness is derived from the signed timestamp of a verified record rather than a transport field.
- The delivery inbox survives a session restart. Communication is addressed to the NODE, and a new session relinks to it — including what was delivered while no session was attached. The inbox was process memory only, so a restart silently wiped the delivery feed while every sender believed it had delivered (observed live: four gate requests, and separately five broadcasts, vanished into restarted peers that showed as live on Bonjour throughout). Ring, sequence and drain cursor now persist together in the node directory: messages without the cursor would replay what was already drained; the cursor without the messages would silently skip the backlog. A missing or corrupt inbox file starts fresh, exactly like the old behaviour.
- Pinned to
@sym-bot/core0.7.0. Shadow samples are now persisted per node rather than reduced to counts in a log line — the receive path writes one row per admission, carrying the receiving node and per-field values. No behaviour change to admission.
- Pinned to
@sym-bot/core0.6.0. Adds an observe-only admission diagnostic on the receive path: computed alongside the five-valued band on every admission, logged beside it, and deciding nothing. No peer can observe it — it never enters the signed attestation payload — so nothing on the mesh can come to depend on its behaviour. - feat:
svafRedundancyThresholdnode option. Every other SVAF threshold has been settable for a long time; the redundancy floor was not settable anywhere, and it is the whole of the redundancy cut. Deliberately carries no default here — unset, core applies its own, so the value keeps exactly one home across the two packages. - ⚠ Note on that option: while C is derived from the five-valued band, C's floor is the
acting gate's floor. Setting
svafRedundancyThresholdtherefore changes what the node admits, not just what the shadow records. Treat it as a production gating change until C becomes an independent cut.
Entry written retroactively on 2026-08-01. This version and 0.8.0 were published without a changelog entry or a git tag; both tags were created after the fact at their own release commits.
- Pinned to
@sym-bot/core0.5.0 — the boundary record model. Two-section records, content-only addressing, per-field keys, and signatures that verify against the author's key rather than the delivering peer's. - Every consumer read migrated. Authorship is resolved from the signed author field; an unattributed block is skipped rather than published under an invented name.
- Pre-boundary history stays readable — older blocks carried as unverified-legacy, not refused.
Entry written retroactively on 2026-08-01 — see the note above.
- cmb-only cutover. v1 key derivation throughout; pinned to
@sym-bot/core0.4.0.
- fix (cross-peer grounding):
remember()with parents now mints the REMIX-schemecmb1-key (§8.2.1 role dispatch). Previously a lineage-bearing authored CMB carried a root-scheme key, failed the receiver's content re-verification, and was hard-rejected as forged — agent-authored grounding CMBs silently never landed on any peer. Two-node regression test included. - feat (
sym emit+sym/emit): MMP Class 1 Emitter (§17.1) — one-shot, signed CAT7 emission to a remote mesh node with the emitter's own persistent identity. No daemon, no store, no identity lock. CLI:sym emit --server <host:port> [--group] [--name] [--to] [--parents] '{...}'; programmatic:require('sym').emit→emitOnce()/connect(). LAN TCP; relay emission lands with one-shot E2E (§18.2.1). Real-TCP e2e tests. - deps: @sym-bot/core ^0.3.48 (tether attestations, recomputeKey export, retroactive-audit evaluation, conformance vectors + schemas).
- Stop a cross-node echo/replay storm — own-only anchors + reload-durable dedup. Two in-memory safeguards that a plugin reload or process restart wiped, plus a missing origin filter, let already-seen CMBs re-circulate across a multi-node mesh:
- Origin filter on anchor exchange (
node.js). On peer connect a node sent its 5 most-recent store entries as SVAF anchors with no origin check, so it re-forwarded CMBs it had merely received from other peers — the A→B→C→A amplifier. Anchors are now the node's own emissions only (peerId == null); a peer learns each node's state from that node directly, so own-origin anchors suffice. - Reload-durable receive dedup (
frame-handler.js). The receive-path dedup cache (_seenCmbKeys) was in-memory only, so a reload made every already-processed CMB look new again. It now persists to a dotfile beside the store (TTL-pruned on load, throttled write, best-effort — any FS error degrades to in-memory), so reloads and version skew are non-fatal. - Verified: own-only anchors forward 0 peer CMBs; the dedup cache rehydrates across a simulated reload; expired keys prune on load. Suite 258/258.
- Origin filter on anchor exchange (
- Nodes self-report their memory stats over the mesh. A node is sovereign over its store, so an observer on another machine can never read it — it could only see a node's broadcasts, never what it admitted. Each node now EMITS its own tally to the roster as a lightweight
node-statsframe (metadata, NOT a CAT7 CMB — it never enters a cognition stream or SVAF):emitted= CMBs it authored (store local count),admitted= CMBs it accepted from peers (store peer count),memory= total. Gossiped on start and everystatsInterval(default 15s).frame-handleringests a peer'snode-statsand the node re-emits it as anode-statsevent for hosts (e.g. the Mesh Edge observer) to render. Self-reported and unsigned — a convenience metric, not stored or treated as authority. Lets any observer show real emitted/admitted counts for every node, including cross-machine agents whose stores are unreadable locally. 3 tests; suite 258/258.
- Earned-authority-weighted attestation aggregation (EA6).
node.aggregateAttestations(cmbKey)folds every attestation about a CMB into a single roster verdict weighted by who actually holds rank. Each attestation is signature-gated against the attester's key from the roster registry (unverifiable ones are excluded as evidence, never weighted), then its attester's role is resolved at its own attestation time (role-at-time) from the rooted grant chain; weight is2^rank(participant 1, validator 2, anchor 4). Overall and per-CAT7-field verdicts become weighted tallies with a deterministicdominant+confidence(dominant's share of total weight); over-claims (claimed role ≠ resolved) are down-weighted to the resolved rank and surfaced inmismatches. So an anchor's admit outweighs a participant's and a node asserting unearned authority cannot inflate consensus. With no anchor pinned, every attester resolves to participant (uniform weight 1) and this reduces to an unweighted tally — it sharpens the moment authority is activated. 2 tests; suite 255/255.
- Roster key registry (EA5) — verify signatures from peers you never directly met. A
nodeIdis a uuidv7, independent of its Ed25519 key, so until now a node could only verify CMBs/attestations/grants from direct handshake peers; a relayed frame from a non-adjacent node failed as unknown-key (invisible on a fully connected LAN, but it capped the protocol at direct connectivity).lib/roster-keys.js—RosterKeyRegistrypinsnodeId→publicKeyby source precedence (anchor>handshake>grant-vouched). A weaker-or-equal source can never repoint a stronger binding; equal-strength key conflicts are refused and recorded as evidence; duck-typesMapget/setso it drops in where the raw key map was used. Persisted append-only.- Keys ride the rooted authority chain. A grant now binds the grantee's key into its signed payload (
granteeKey,@sym-bot/core0.3.45), so a node that never handshook the grantee learns its key from a rooted grant — tamper-evidently, because swapping the key breaks the grantor's signature (the relayer never vouches). The store pins a grantee key only when the grant is role-effective (grantor actually held the rank), so an unrooted or over-reaching grant vouches for nothing and cannot poison the registry. - Node wiring — handshake pins at
handshakestrength (_pinPeerKey, writing both the legacy CMB map and the registry); attestation / checkpoint / witness verification now resolves keys via_identityKey(registry first, covering relayed + persisted bindings);grantRolebinds the grantee's known key. 9 tests; suite 253/253.
- Earned authority — the lifecycle role a node claims is now earned and verifiable, not self-asserted (MMP §6.5). A node's validator/anchor authority flows only along signed role-grant chains that terminate at a pinned, non-earnable anchor (the founder root); over-reaching, unrooted, and cyclic grants confer nothing (Douceur — authority must bottom out at a pinned root).
lib/role-grant-store.js—RoleGrantStoreholds signed role-grant / role-revoke records and resolves "what role did node N hold at time T" by walking the chain. Signatures verify on ingest against the grantor's announced key (anchor key pinned); whether the grantor actually held the rank is a resolve-time, role-at-time property, so an unentitled grant is stored but inert. Persisted append-only, reloaded on construction.lib/node.js— the node pins an anchor (opts.anchororSYM_FOUNDER_ANCHOR="nodeId:publicKey"), holds the grant store, and stamps its resolved role into attestations + witnesses (soverifyAttestationRolechecks the stamp against the chain).grantRole/revokeRolesign + gossip a grant;resolveRole(nodeId, at)exposes chain resolution. With no anchor configured the node falls back to the staticlifecycleRole(backward compatible).lib/frame-handler.js— ingestsrole-grant/role-revokeframes (store verifies, relay-once).- §6.5 enforcement —
MemoryStore.validateCMBnow requires the caller's resolved role to rank validator-or-above;canonizeCMBrequires anchor.node.validateCMB/node.canonizeCMBresolve this node's earned role and let the store enforce, so a participant can advance no CMB's lifecycle. - 17 tests (10 store + 7 node). Earned authority is dormant until an anchor is pinned — until then every node uses its static role, unchanged.
- Admission Attestation audit trail is now durable across restarts. The attestation index persists every record append-only under the node dir (
attestations.jsonl/checkpoints.jsonl/witnesses.jsonl) and reloads it on startup, so the cross-mesh audit trail — attestations, Merkle checkpoints, and witness countersignatures — no longer evaporates from memory when a node restarts. Append-only matches the compliance model (never rewrite); a corrupt line is skipped, never fatal; persistence failures never break gating. On reload the node restores its per-attester chain cursor (seq/head) from its own reloaded chain, soseqstays monotonic andprevkeeps linking across the restart boundary — otherwise a restart would reset the chain to genesis and read as a false omission. The guarantee is now: tamper-evident + omission-evident to the last witnessed checkpoint, and durable across restarts.
- Admission Attestation gossip + cross-mesh audit trail with omission-evidence (Phases D1–D3; requires
@sym-bot/core^0.3.43).- D1 — index + every gate attested.
lib/attestation-store.js: a per-node index keyed by gated-CMB (the audit trail for a CMB) and by attester chain (seq). The gate now attestsrejectandredundanttoo (a refusal is the compliance-critical event), so the per-attester chain covers every gating event.verifyChainflagsseqgaps andprevbreaks — local omission-detection. - D2 — roster gossip. Every attestation is gossiped on a dedicated
attestationframe to roster peers (same-group by mDNS isolation). On receipt the node verifies the attester's original Ed25519 signature against its authenticated identity key (a relay never vouches), rate-limits per(of,by)against a flood, records, and relays once (epidemic spread). Forged/invalid dropped. - D3 — checkpoints + witnessing. The node periodically commits a signed Merkle checkpoint over its attestation chain (
merkleRootof the ordered signatures to seq N); roster peers verify and countersign it (witness).reconcileChain(by)recomputes the root over the held chain vs the witnessed commitment — so dropping any attestation ≤ N after it is witnessed makes the root diverge. Cross-node omission-evidence. - Guarantee: tamper-evident + omission-evident to the last witnessed checkpoint, not real-time completeness. Remaining (D4): roster key registry for relayed-attester verification + role-at-time, witness-quorum tuning, durable persistence.
- D1 — index + every gate attested.
- Admission Attestations persisted on the gated remix (Phase C; requires
@sym-bot/core^0.3.42). When the SVAF gate ADMITS a CMB, the node signs an Admission Attestation and attaches it to the stored remix (cmb.admission) as the durable, attributable audit record:{ of, by, at, roster, method, verdict, fields, role, seq, prev }, signed with the node's Ed25519 identity key.ofbinds the original gated CMB key; the per-fieldfieldscome from the gate's verdict (heuristic) orfield_driftsmapped throughcomputeFieldVerdicts(neural). A per-attester hash-chain (seqmonotonic,prev= sha256 of the previous signature) makes a dropped attestation a detectable gap (omission-evidence backbone; in-memory for now).roleis the node's claimed lifecycle role — consumers verify it against the rooted role-grant chain, never the stamp. The attestation is a CMB-envelope sibling, so it does not affectcmbKeyor any existing signature. (Reject/redundant attestation, the queryable index, and mesh-wide gossip are the next phase.)
- Opaque payload survives the SVAF-admit remix path. A directed CMB's
payload(the substrate-level data riding alongside CAT7 — e.g. the LLM request/response primitive) was dropped whenever the receiver SVAF-admitted it: the fused remix is rebuilt from CAT7 fields and the heuristic fusion returns a freshcmbwith no payload, so the payload vanished before reaching the inbox. The same CMB rejected-but-directed surfaced the raw message and kept its payload — so payload delivery silently depended on the receiver's per-node SVAF drift. That was the root of the cross-device "payload arrives on some peers, not others" asymmetry (a receiver that admits drops it; one that rejects keeps it) — not an OS or transport difference._preserveIncomingPayloadre-attaches the incoming payload onto the fused remix on both the neural and heuristic store paths; payload rides alongside CAT7 and is never part of thecmbKeyhash, so an admittedllm-request/llm-responseremix correctly carries its substrate data. Regression-tested intests/frame-handler-payload.test.js(unit) andtests/integration/e2e-payload-receive.js(two-node e2e). Completes the cross-device payload fix begun in 0.7.18 (which covered only the inbox pull path).
- Inbox pull path preserves the opaque payload. A CMB's
payloadis a sibling ofcmb.fields, but_pushInboxcopied onlyfields— so any CMB pulled vianode.inbox()(thesym_receive/sym_fetchpath) silently lost its payload, while the channel-push path (readingentry.cmb.payload) kept it. Structured agent-to-agent data now survives cross-device directed delivery on both paths. Regression-tested intests/inbox.test.js.
- Reconciliation release: 0.7.16 (store rename + migration + role/EIP renames) was published off a stale main; 0.7.17 is the same content rebased onto origin, now also including #31 (lib-level resolveAvailableName) and #33 (ws 8.21.0). No new changes beyond the merge.
- Per-node CMB store dir renamed
meshmem/→cmbs/(it stores CMBs). A fresh node self-migrates its own dir on construct;migrateStores()(exported) bulk-renames every NON-live node at sym/mesh-channel install (live nodes are skipped — they self-migrate on restart). Readers readcmbs/only — clean break, no fallback. Very oldmemories/stores still migrate via the field-mapped path.
- CLI
sym observe→sym publish(+ the bundledsymskill), matching the MCP tool rename to canonical EIP verbs. Publishing emits a projection of the agent's state; the cognitive mechanism terms stay in the spec. Clean break — no alias.
- Lifecycle role
observerrenamed toparticipant(MMP normalization). Frees the "observ-" stem for the projection/observation distinction (an emitted CMB is a projection; an admitted one a peer's observation).participant → validator → anchor; handshakelifecycleRoledefault is nowparticipant. Clean break — no backward-compat alias.
- Loopback registry self-cleans on abrupt exit. A node writes
~/.sym/loopback/<nodeId>.jsonon start andstop()unlinks it on graceful teardown — but a process that exits or is killed without callingstop()(test runs that just finish, Ctrl-C) left the registration behind as a stale "group" until a pid-liveness check filtered it out. Now a one-shot sync unlink is registered onprocess.on('exit')so the common case self-cleans; the listener is removed again instop(). (SIGKILL still can't be caught — that residue is what the pid-liveness check in readers is for.)
-
IPC
remembercarries lineageparents. The daemon IPCremembernow forwardsopts.parents(each{ key }) through tonode.remember, so a CMB emitted via the IPC client (e.g. mesh-edge'semitCMB) can declare its source as a remix edge (MMP §14) rather than bare fields. -
Adaptive integration timescale plumbing for SVAF (the liquid substrate). New node options
svafAdaptiveTimescale/svafMinFreshnessSeconds/svafReactivity/svafChangeWeights/svafRecentWindow, a bounded ring of recent SVAF verdicts, and call-site plumbing passingrecentDecisions+ the adaptive config into@sym-bot/core'sprocessHeuristicSVAF(requires@sym-bot/core ^0.3.39). When enabled, the SVAF memory-decay timescale shortens after recentguarded/rejectedverdicts and lengthens when stable — the content gate driving the temporal timescale, instead of a fixed-gain integrator. Off by default. The decision log now recordseffectiveTau+changeSignalper heuristic admission.
- Ingestion flag on surfaced CMBs (MMP §9.2.2). Because directed delivery and SVAF memory admission are decoupled, a surfaced CMB now declares whether the receiver ingested it (remixed into memory with lineage →
remixed: true) or only delivered it (surfaced to the agent but not stored →remixed: false, the directed-but-SVAF-rejected case), alongside the SVAFdecision. Consumers checkremixedto know whether a directed request is recallable from memory later or transient. Covered by four cases intests/inbound-cmb-surfacing.test.js.
- Directed (peer-bound) CMBs now surface unconditionally (MMP §4.4.4 / §9.2.2). A CMB sent to a specific recipient (
sym_send to=X) is a request between two agents and must reach the receiving agent regardless of the SVAF verdict — previously every inbound CMB (directed or broadcast) ran through the group-autonomous SVAF surfacing gate, so a directed coordination CMB scored low (redundant/foreign) by SVAF was silently dropped. Now the send path marks the wire frame withto+directed, and the receiver surfaces a directed CMB exactly once: on SVAF admit via the existing store path, on SVAF reject/redundant via a dedicated delivery path. SVAF governs memory admission only for directed CMBs, never delivery. Group-bound broadcasts (sym_observe, noto) stay fully SVAF-gated for surfacing — unchanged.
- Inbound-CMB receive fix — record dedup key after surfacing, not before. The receive-path dedup recorded a CMB's content-hash key as "seen" before it had actually surfaced, so a first pass that surfaced nothing (an SVAF reject with neutral mood) still poisoned the key — the same CMB re-arriving on the next reconnect was deduped and silently dropped, leaving the node receive-blind. The key is now recorded only after the CMB genuinely surfaces, preserving the anti-replay-storm guarantee without swallowing undelivered CMBs.
- SVAF decision log. Every SVAF evaluation —
aligned/guarded/redundant/rejected— is now persisted and emitted, not just the admitted CMBs. A node's autonomous, per-field admission — including the rejections the memory store never keeps — is observable:node.decisions({ limit, since, decision, source }), a livesvaf-decisionevent, and an append-only, capped log at~/.sym/nodes/<name>/decisions/log.jsonl. Each record carries the per-fieldfieldDrifts+gateValues, the evaluated CMB key, and a short focus label — never the rejected payload (local-first, label-only). Capped (default 2000;SYM_DECISION_LOG_CAP); opt out withSYM_DECISION_LOG=0. Additive + backward compatible —meshmemand all existing readers are unchanged.
- Mesh replay-storm receive-path dedup (#32). Received CMBs are deduped by content-hash, so already-seen CMBs are no longer re-surfaced or re-broadcast. Stops the loop where nodes re-dumped their stored history on every (re)connection, flooding the mesh with stale CMBs.
- Same-host loopback discovery — co-resident SymNodes now mesh with no network interface. mDNS multicasts over an interface, so with Wi-Fi off two nodes on one host couldn't discover each other even though each already listens on a TCP port and could connect over
127.0.0.1.BonjourDiscoverynow runs a filesystem-registry path alongside Bonjour: each node advertises its loopback endpoint to~/.sym/loopback/<nodeId>.json({nodeId, name, port, pid, serviceType, ts}) on a 5s heartbeat, scans for live (pid-alive +ts < 30s), same-serviceTypepeers, and emits the existingpeer-foundevent over127.0.0.1. No transport/protocol change — the connection + handshake path is identical to Bonjour. Group isolation byserviceType(MMP §5.8); the lowernodeIddials (mirrors the Bonjour tie-break) so the pair connects exactly once; quick catch-up scans (300/1200/3000ms) mesh near-simultaneous starts in ~1s.
- Additive + backward-compatible: activates on next node start — no config, no forced restart, Bonjour path byte-for-byte unchanged. Both nodes must run ≥0.7.4 for same-host loopback meshing (each side must register and scan).
sym groupsnow lists groups cross-platform (including Windows) via a discovery beacon, replacing thedns-sdshell-out (which doesn't exist on Windows, and whichbonjour-servicecan't substitute for because it doesn't answer the DNS-SD meta-query). Every running daemon now advertises its group on a shared_symgroups._tcpservice (group name in TXT) via the bundled pure-JSbonjour-service;sym groupsbrowses that beacon and lists the live groups with their nodes. Discovery-only — comms stay isolated on each group's own_<group>._tcp. Group names may be anonymous (opaque codes), so the LAN listing need not reveal a group's purpose.
- Groups are open-join in this release (anyone who knows the name can join). Invite-gated private groups (admin-set, join-by-invite, LAN handshake gating) are the planned fast-follow.
sym askstill crashed on Windows in 0.7.1 — the 0.7.1 broadcast-socket fix was necessary but not the culprit. COO bisected the crash to the synthesis path:process.exit(0)was firing while the LLM call's handles (a spawnedclaudesubprocess's stdio pipes, or a fetch socket) were still closing, tripping the libuvUV_HANDLE_CLOSINGassertion (0xC0000409). Fix:sym askno longer force-exits — it sets the exit code and lets the event loop drain naturally (so closing handles finish cleanly), with a deferred unref'd fallback that force-exits only if an idle keep-alive handle lingers (by which point nothing is mid-close). Verified prompt clean exit (≈130ms, no provider).
sym askcrashed on exit on Windows with a libuv assertion (!(handle->flags & UV_HANDLE_CLOSING),win/async.c, exit0xC0000409). The best-effort broadcast socket was closed withsocket.end(), which leaves the named-pipe handle mid-close; when the command thenprocess.exits, Windows aborts. Nowsocket.destroy()tears the handle down fully (and the timeout is cleared) before exit. Mac/Linux unaffected either way.sym groupserrored withspawn dns-sd ENOENTon Windows (Apple Bonjour'sdns-sdisn't installed). It now degrades gracefully — a clear message that LAN group enumeration needs the tool, while noting the node still meshes via the bundled pure-JSbonjour-serviceand you cansym join <name>directly. Also fixed a timer-ordering bug in the discovery path.
(Cross-machine mesh, install, and daemon lifecycle all PASSED on Windows in 0.7.0 — these two were the only issues.)
- Mesh group commands (MMP §5.8).
sym start --group <name>joins a group at launch (+--relay-url/--relay-tokenfor WAN);sym join <name>switches into one,sym leavereturns to the default mesh,sym groupsdiscovers groups live on the LAN,sym groupshows the current one. A group is the "group chat" boundary — only nodes in the same group discover each other and exchange CMBs.lib/groups.jsis the single source of truth for the group↔serviceType mapping (default → _sym._tcp,<kebab> → _<group>._tcp), matchingsym-mesh-channel(the Claude MCP node) and sym-swift so CLI, app, and Claude peers meet in the same group. The daemon resolves the group fromSYM_GROUPenv → persisted~/.sym/group→ default (the file is the cross-platform source of truth across launchd/spawn restarts) and logs it on startup.
- Windows portability.
sym stopno longer shells out topgrep(POSIX-only, absent on Windows): the daemon's pid is tracked in~/.sym/daemon.pidand stopped viaprocess.kill(pgrep kept only as a Linux fallback).isDaemonRunningnow checks pid-file liveness on Windows instead of always returningtrue. The group-switch restart uses a portableAtomics.waitsleep instead ofexecSync('sleep'). macOS (launchd) path unchanged.
- README (EN + ZH) reframed around the node × reach × scope model: the daemon is the polyglot, real-time node (any language joins via shell-out +
sym listen), not optional; a new Groups section; Privacy reconciled with the relay (local stays local; remote forwards E2E-encrypted bodies through your own authenticated relay). The agent SKILL teaches the group commands.
sym ask "<question>"— ask the whole mesh one question and get one synthesized answer. Broadcasts the question to the mesh (best-effort; live agents can contribute and it's logged with lineage), gathers the contributions every peer has fused into shared memory (~/.sym/nodes/*/meshmem, ranked by keyword overlap with the question, falling back to most-recent for context), and synthesizes a single answer with the configured LLM provider — each point cited to the agent that supplied it. With no provider configured it prints the ranked raw contributions and their sources instead of erroring, so it always returns what the mesh knows. Flag:--raw(skip synthesis, show contributions). This is the headline experience: ask the mesh directly, instead of asking one agent and getting one perspective.complete(opts)+hasProvider(opts)exported fromlib/llm-reason.complete()is a free-form sibling ofinvoke()— same Anthropic / OpenAI-compatible / Claude-CLI providers, returns raw text instead of extracting CAT7 (throwscode: 'NO_PROVIDER'when no key / CLI provider is configured).hasProvider()reports whether a provider is configured without making a network call. Used bysym ask; available to any caller needing plain LLM completion over the mesh's provider config.
- Skill teaches
sym ask. The SYM agent skill gains an "Asking the mesh a question" section so agents query the whole mesh when a question spans other agents' domains. The.agents/and.claude/skill copies — which had drifted — are reconciled to one canonical source (.agents/), with the.claude/-only "Real-time listener" section ported in so nothing is lost. - README refocused on a single capability — collective intelligence: ask the mesh, answer as one mind. Defines "the mesh" in plain language up front, answers What / Why / How in the first screen, headlines
sym ask, and moves the heavy config / drift-math inline reference to spec pointers.
- 6 offline tests for
sym ask(gather + relevance ranking, empty-mesh, no-provider fallback, usage) plus thellm-reasonsynthesis exports. No paid API in CI. Full suite 162 passing.
opts.payloadonSymNode.remember(fields, opts)— optional opaque payload attached to the CMB alongside CAT7 fields. Rides the wire frame (the existingpeer.transport.send({ type: 'cmb', timestamp, cmb })path serializes the whole cmb object, so payload propagates automatically) and the local store. NOT part ofcmbKey— CAT7 fields alone remain the content-addressed identity, preserving cross-SDK CMB dedup with sym-core-swift. Substrate-level protocols (LLM request/response primitive, sym.day ATTACH-DATABASE) carry data beyond CAT7 through this slot without violating MMP §8 semantics by smuggling JSON throughmotivationor other CAT7 fields. Senders ensure CAT7 fields differ when payloads differ (e.g. unique request_id infocus) to avoid store-side dedup collisions on the CAT7 hash. Backward-compat: whenopts.payloadis omitted, CMB serialization is identical to v0.5.7.
- Old peers receiving payload-bearing CMBs ignore the unknown field (existing
cmb-acceptedhandlers only readentry.cmb.fields/entry.content). Forward-compat. - New peers receiving non-payload CMBs from old peers see
cmb.payload === undefined. Backward-compat. cmbKey()algorithm unchanged → CMB identity remains stable across the v0.5.7 / v0.5.8 boundary.
- Origin-aware retention.
MemoryStore.compactByOrigin(localFreshnessMs, peerFreshnessMs)lets callers move self-authored CMBs (peerId == null) and peer-received CMBs to cold tier on independent freshness thresholds. Useful when the agent's own lineage chains carry more retrospective value than peer chatter — apps configure local > peer freshness so their own emissions survive longer. SymNodeconstructor optslocalRetentionSeconds+peerRetentionSeconds. Optional overrides of the uniformretentionSeconds. When omitted, both fall through toretentionSeconds(back-compat preserved). When set,_runRetentionPurge(run on start + hourly) uses the new origin-aware path and logs both values when they differ.
MemoryStore.compact(freshnessMs)is now a back-compat shim that callscompactByOrigin(freshnessMs, freshnessMs). No behavioral change for callers that don't explicitly opt into origin-aware retention.- Existing apps configured with only
retentionSecondssee no behavioral change. The new opts are purely additive.
- 2 new tests in
tests/memory-store.test.js: shim equivalence (back-compat) + origin discrimination (peer compacts past peer cutoff while self stays hot under local cutoff). 18/18 pass.
- Apply 1s stale-prior threshold to
_createPeerpath. v0.5.5 lowered the threshold to 1s in the inbound-connection handler but left the_createPeerpath on the old_heartbeatInterval(10s). When both sides of a peer pair dialled each other in rapid succession, the inbound handler accepted with the 1s rule but the merged_createPeerre-evaluated with 10s and could pick the opposite winner. Mac-side and Node-side then kept different connections, each killing the other's choice — visible in field testing as a continuous ~6s join → disconnect cycle even after v0.5.5. Aligned both sites on 1s.
-
Stale-prior threshold lowered from 10s to 1s. v0.5.3+v0.5.4 introduced lastSeen-aware stale detection in the inbound-connection and
_createPeerdedup paths, with the threshold tied to_heartbeatInterval(default 10s). Field testing showed this was too lenient: when a peer process was killed and quickly relaunched, the old run had typically sent a CMB seconds before death, solastSeenwas still within the 10s window. The dedup logic then rejected the legitimate redial as a same-direction-duplicate, producingconnection ready → immediate disconnectwith no handshake-complete on the dialing side.Lowered to a hardcoded 1s threshold in both dedup paths. Sub-second TCP-retry races during initial handshake still keep prior (the case the same-direction-duplicate rule was designed for); peer restarts with ≥1s between kill and re-dial now recover within the application layer instead of being blocked until OS keepalive reaps the underlying socket (~100s).
-
Replacement transports never received a handshake; remote rejected the next heartbeat-
pingas a protocol violation. Companion fix to v0.5.3. When the dual-dial dedup or stale-prior swap path in_createPeerreplaced an existing transport, the new transport was registered inexistingPeer.transportsbut no handshake was sent on it —_addPeer(which sends the handshake) is only called for brand-new peers, not transport replacements. The remote (sym-swift) saw the new connection reach.ready, sent its own handshake, and waited for ours. Ours never arrived. ~10 seconds later the heartbeat tick firedpingon every transport, the remote sawpingas the first frame, and disconnected with[SYM] session: expected handshake, got ping— protocol violation. Net result: a flap loop where every reconnect was killed within 10s by the protocol-violation trip-wire.Fix: extracted handshake-build into
_buildHandshake()helper; the existing-peer branch in_createPeernow sends the handshake on every newly-registered transport. Idempotent — if the remote already sent its handshake, it processes both fine.Verified end-to-end on Mac Catalyst MeloMove ↔ claude-code-mac (Node) on the same Mac. Connection stays stable, peers persist in the UI, CMBs flow continuously without the periodic 10s drop.
-
Same-host loopback peers stayed permanently rejected after one peer restarted. Companion fix to 0.5.2's same-host dedup. v0.5.2's stale-prior check looked only at the transport's
_closedflag — set whentransport.close()had been called explicitly. But the common dead-but-ESTABLISHED case (peer process killed; OS doesn't deliver FIN to the survivor before macOS keepalive reaps it) leaves_closed=falseforever. On loopback this is a hard block — macOS default TCP_KEEPALIVE is 7200 seconds (2 hours) before the first probe. The survivor sees the dead socket as alive, and the dedup logic against this zombie entry rejects every redial from the restarted peer.On Wi-Fi the same logical bug is much less visible — mobile TCP routes are noisy (route flaps, ARP timeouts, AP transitions) and keepalive idle defaults are short, so stale sockets die in seconds. On loopback there's zero noise; the dead socket sits in ESTABLISHED indefinitely.
Observed: Mac Catalyst MeloMove ↔ claude-code-mac (Node) on the same Mac. Each Mac MeloMove rebuild → claude-code-mac retains a dead ESTABLISHED socket → new Mac MeloMove's redial is rejected for 2h. iPhone ↔ claude-code-mac on Wi-Fi recovers within seconds because Wi-Fi noise reaps stale sockets quickly.
Three-part fix:
-
TcpTransportenables TCP keepalive on the socket —socket.setKeepAlive(true, 1000). 1-second initial idle delay before OS keepalive probes start, then OS-default probe cadence. macOS detects dead remote in ~10s instead of ~2h. -
inbound-connectionhandler and_createPeernow treat stale-by-lastSeenas stale. A prior peer entry whoselastSeenis older than_heartbeatInterval(default 10s) is now considered stale regardless of the_closedflag. The remote re-dialling is itself strong evidence its prior is dead — a healthy peer wouldn't dial again. Closes the dead prior explicitly so its close handler runs and removes the dict entry before the new transport is registered. -
Identity-aware close handlers. When a stale prior is closed and replaced, its eventual close handler must NOT clobber the new transport entry. Both close handlers in
_createPeernow guard withtransports.get(source) === transportbefore mutating the transports dict. Prevents a late-firing close from a swapped-out prior tearing down its replacement.
Affects all peers running on the same host as another sym instance, and any peer-restart scenario where the peer's TCP socket on the survivor side stays in ESTABLISHED state past the OS-level FIN.
-
-
Same-host Bonjour peers permanently rejected each other. When two
@sym-bot/sym(or sym-swift) processes ran on the same host and Bonjour-discovered each other, neither could maintain a peer relationship. Theinbound-connectionhandler and_createPeershort-circuited the moment a same-source transport key was present inpeer.transports, regardless of whether that prior was actually alive or what direction it was in. Three real failure modes collapsed into the same bug:- Stale prior — the previous transport's
_closedflag was set but its close handler hadn't fired yet. Apple's Network framework doesn't always deliver FIN promptly when a peer process exits abruptly, leaving a dead entry in the transports map. Any reconnect attempt was permanently rejected until the OS reaped the dead entry. - Same-direction duplicate — listener fires
newConnectionHandlertwice for the same advertised service (TCP retry, multipath race, repeated Bonjour resolution). Silently replacing the established healthy inbound with the duplicate tore down the wire pair on the remote side and triggered peer-left storms. - Dual-dial collision — both peers Bonjour-discovered each other within ~50ms and both initiated outbound TCP. Each side held one outbound + one inbound for the same nodeId. The unconditional reject killed one side's view of the connection, leaving asymmetric peer state.
Observed in the field on macOS: a Mac Catalyst app (sym-swift) and a Node CLI (
@sym-bot/sym) on the same Mac would never maintain a peer relationship — the Node side silently rejected the Catalyst side's inbound dial viatransport.close(); return;. Cross-host LAN peers worked because the timing windows differ.Fix is two-part, applied in both
inbound-connectionhandler and_createPeer:- Stale-aware dedup — short-circuit only when the prior transport
is alive (
!_closed). A stale_closed=trueentry is treated as no prior; the new connection replaces it. - Direction-aware dedup with deterministic tie-break — for a live
prior:
- Same-direction duplicate (both inbound or both outbound) → keep prior, drop new (no wire-pair teardown on the remote).
- Dual-dial collision (different directions) → nodeId-based tie-break. The lower nodeId acts as client and keeps its outbound; the higher keeps the matching inbound. Both peers independently compute the same physical-socket winner without exchanging coordination frames.
Mirrors the equivalent fix shipped in
@sym-bot/sym-swiftv0.3.79 + v0.3.80 so cross-runtime peers (sym-swift ↔ sym Node) now agree on the same dedup convention.Affects all peers running on the same host with another sym instance, and any deployment where Bonjour discovery races finish within ~50ms of each other.
- Stale prior — the previous transport's
-
Mac↔Windows peer connections over LAN.
BonjourDiscoverynow publishes an explicithostfield with a normalized mDNS-valid hostname (.localsuffix). On Windows,os.hostname()returns a bare NetBIOS name (e.g.xmesh-hp) with no domain suffix;bonjour-servicepreviously advertised that verbatim as the SRV target. macOS mDNSResponder only resolves the.local.TLD, so the Mac could discover the Windows peer via mDNS browse but failed to open an outbound TCP connection — hostname resolution returnedNo Such Record. CMBs sent to Windows peers never arrived; no replies ever came back. (Same class of bug as the 0.3.72 cross-platform resolve fix; regression path was thehostfield being unset.)Fix is two-part:
config.loadOrCreateIdentity()normalizesidentity.hostnamevia the newnormalizeMdnsHostname()helper — bare names get.localappended, FQDNs and already-.localnames pass through. Existing identities with bare hostnames are auto-migrated on next load.BonjourDiscovery._startBonjourFallback()passes the normalizedidentity.hostnameas thehostfield tobonjour.publish()so the SRV target matches.
Affects all peers; Windows nodes must upgrade (their advertisement was broken). Mac nodes benefit from the explicit
host:field for determinism even thoughos.hostname()happens to produce.local-suffixed output on macOS.
-
node.buildStartupPrimer({ maxCount, maxAgeMs })— reconstitute an agent's remix memory as a human-readable primer, suitable for injection into the LLM context at session start. Operationalises MMP §4.2 O2 (rejoin-without-replay). A fresh agent session wakes with its prior cognitive state already loaded — zero first-turnsym_recalloverhead. Returns{ text, count, dropped, totalInStore }. Defaults:maxCount=20,maxAgeMs=86_400_000(24h). Recency window applied first, then count cap. Empty store yields an empty primer.Intended use — call as the final step of plugin initialisation:
const node = new SymNode({ name, ... }); await node.start(); // ... transport, tool surface, subscriptions ... const primer = node.buildStartupPrimer(); mcpServer.instructions += '\n\n' + primer.text;
Inherits to every plugin that depends on
@sym-bot/sym. Consumers:@sym-bot/mesh-channelv0.3.0,@sym-bot/melotune-pluginv0.1.7.
- Remix CMB key self-reference on first-observation (MMP §14).
Pairs with the
@sym-bot/core0.3.36 fix. When neural SVAF admits an incoming CMB,_processNeuralSVAFnow mints a fresh remix key viaremixKey(fusedFields, incomingKey, this._node.name)and overwrites bothfusedEntry.cmb.keyandfusedEntry.keybefore remix-store. Previously the receiver preserved the sender's CMB key on the stored remix, producinglineage.parents=[remix.key]— a self-edge that broke DAG traversal. Fix guarantees remix key ≠ parent key by construction while keeping idempotent dedup for retries from the same sender to the same receiver. The heuristic SVAF path is fixed in@sym-bot/core0.3.36.
@sym-bot/coredep bumped to^0.3.36forremixKey+ heuristic-SVAF fix.
- MMP §5.8 mesh group membership.
SymNodeacceptsopts.group(default"default") andopts.discoveryServiceType(default"_sym._tcp"); both are propagated intoBonjourDiscoveryfor LAN-layer isolation. The handshake frame version is bumped0.2.2→0.2.3and carries the optionalgroupfield per §5.2. Matches thesym-swiftSymNode(discoveryServiceType:)parameter so Node and Swift implementations align. - MMP §4.4.4 targeted CMB send.
SymNode.remember(fields, opts)now acceptsopts.to(full peerId). When set, the CMB frame is emitted only to that connected peer; when omitted, behaviour is unchanged (broadcast to all peers). The local store write runs in both cases — lineage and §14.7 remix-guard invariants are enforced identically. peers()exposespeerId(full nodeId) alongside the truncatediddisplay form, so external callers can resolve a peer by name to a full peerId without reaching into internal_peersstate.tests/remember-targeted.test.jscovering broadcast regression, targeted send to connected peer, targeted send to disconnected peer, andpeers().peerIdexposure.
frame-handler.jsmoved from@sym-bot/core. FrameHandler is protocol plumbing — frame routing, store writes, event emission — and belongs in the protocol/node package. Imports now resolve to the local copy;@sym-bot/coreretains a backward-compat re-export.- Echo loop prevention (MMP Section 14).
_handleMemoryShare()now checks whether incoming CMB lineage parents exist as local keys in the memory store. If so, the CMB is a derivative of our own broadcast and is silently dropped — preventing ping-pong between same-app peers. MemoryStore.hasLocalKey(key)— returns true if a CMB key exists in local (non-peer) entries. Used by the echo loop guard.
- Bump
@sym-bot/coredependency to^0.3.35.
- Bump
@sym-bot/coreto 0.3.33. Migrates@xenova/transformers→@huggingface/transformers@^4.0.1. Eliminates deprecatedprebuild-installand the EBUSY DLL lock on Windows.
- Clear socket timeout after TCP connect.
_connectToPeerset a 10-secondsocket.setTimeoutas a connect timeout but never cleared it after success. The timeout kept firing on the CONNECTED socket, killing any LAN connection idle for >10 seconds. Connections now stay open indefinitely after establishment.
- Fresh mDNS re-browse on reconnect timer. The 15s reconnect timer now restarts the bonjour-service browser (fresh mDNS query) instead of retrying stale cached addresses/ports.
- On-demand reconnect on send failure.
node.send()triggers an immediatediscovery.reconnect()when delivery returns 0 peers, instead of waiting for the next 15s timer tick.
- LAN reconnect timer. Discovered peers are cached. Every 15 seconds,
peer-foundis re-emitted for cached peers not currently connected. Handles TCP drops without requiring a process restart.
- Removed leader-election gate from bonjour discovery. Both sides
now emit
peer-foundand attempt to connect. The old gate (only the lower nodeId initiates) was fragile: stale bonjour cache on the initiator side → no connection, because the other side was gated.
- Prefer IPv4 in bonjour-service discovery.
service.addressesfrom bonjour-service can include IPv6 link-local (fe80::...) which requires a scope ID for TCP. Now picks the first IPv4 address.
- Cross-platform LAN discovery: use
bonjour-serviceinstead of nativedns-sdbinary. The macOSdns-sd -Lresolve step uses unicast DNS-SD queries that fail to resolve services advertised by Windows' Bonjour implementation. Browse (multicast) works, but resolve (unicast) returns empty — so Mac discovers Windows peers but can't get their port, and the TCP connection never happens. Thebonjour-servicenpm package uses multicast for both browse AND resolve, which works cross-platform. Verified Mac↔Windows on the same wifi (2026-04-09). Thedns-sdbinary code path remains inlib/discovery.jsas dead code for reference but is no longer called.
windowsHide: trueadded to all 10 child_process spawn sites so Windows agents (especially the four Centro pm2 agents) no longer flood the desktop with cmd.exe popup windows on every git query, python resolution, port lookup, etc. Sites: 7 inlib/platform.js(resolvePython× 2,resolveClaudeCLI,findProcessByPort× 2,findProcessByName× 2,safeExecdefaults) and 3 inlib/discovery.js(dns-sd -Rregister,dns-sd -Bbrowse,dns-sd -Lresolve).lib/llm-cli.jsalready had it. No-op on macOS/Linux. Catalogued by claude-code-win during the 2026-04-09 cross-machine round-trip session.
- Identity lockfile prevents two SymNode processes from claiming the
same nodeId on the same host.
~/.sym/nodes/<name>/lock.pidis acquired in the constructor and released instop(). Cross-process duplicates throwEIDENTITYLOCK; same-PID re-acquisition (tests, hot-reload) is allowed; stale locks (dead PID) are reclaimed automatically. Catches thesym-daemon+ MCP server collision that silently broke real-time push on Windows. SeecliHostMode-vs-MCPbug from 2026-04-09 round-trip test. node.send()now returns the actual delivered count. Previously returned undefined; sym-mesh-channel had to readpeers().lengthseparately, which could disagree with reality (peers in_peerswith broken transports)._broadcastToPeers()now wraps eachtransport.send()in try/catch and counts successes. Backwards compatible — existing callers ignoring the return value continue to work.
SymNode now acquires a lockfile on construction. Hosts MUST wire
SIGTERM/SIGINT to call node.stop() so the lockfile is cleaned
up — otherwise stale locks accumulate (they're auto-reclaimed on
next startup, but cleaner shutdown is better). sym-mesh-channel
v0.1.3+ already does this.
If two of your processes legitimately need different identities,
set SYM_NODE_NAME to distinct values per process. If they're
fighting for the same identity by mistake (e.g. inherited shell
env), the lockfile error message will tell you which PID holds the
existing claim.
- Excluded
*.bak,*.swp,.DS_Storefrom published tarball via.npmignore. 0.3.68 accidentally shipped local backup files. Same code as 0.3.68; deprecate 0.3.68.
RelayConnectionno longer silently reconnects on close code 4004 ("Replaced by new connection"). Logs FATAL, sets a hard-stop flag, fires the newidentity-collisionevent, and exits the close handler. Breaks the duplicate-identity ping-pong loop. Seed6a17f6.
identity-collisionevent onSymNode—{ nodeId, name, code }. Optional listener; default behavior is loud-log + stop reconnecting. Hosts wanting hard-exit semantics should listen and callprocess.exit()themselves.
Two processes holding the same nodeId would enter a 1s ping-pong loop on the relay, flooding peer-left/peer-joined events. MMP principle: identity is bound to a keypair, so two simultaneous holders is an error condition — refuse loudly instead of silently retrying.
sym observe --standalone— daemon-less one-shot CMB emission. Spins up a freshSymNodeinside the CLI process, reads relay credentials from~/.sym/relay.env, emits one CMB, and disconnects. Works even whensym-daemonis not running — the daemon becomes an optimisation, not a requirement. Auto-enabled as a graceful fallback whenever the daemon is down, so existingsym observecommands no longer fail with "sym-daemon is not running."sym observe --name <id>— set the mesh identity for standalone-mode emissions. Defaults tosym-cli. Identity is stable across invocations via the cachedSymIdentitykeypair in~/.sym/nodes/<name>/, so repeated calls with the same name resolve to the samenodeId. Claude Code users should pass--name claude-code-mac(orclaude-code-win/claude-code-linux) so their CMBs are attributable on the mesh grid.sym observe --parents <key1,key2>— comma-separated parent CMB keys for remix lineage. Using this flag implies--standalone(the daemon IPCrememberhandler does not accept lineage parents). Makes it trivial to emit resolution CMBs that close upstream tickets on the Review Board via the SVAF lineage graph.
Before this release, sym observe required sym-daemon to be running
— any user who had stopped the daemon (or never started it) hit a hard
failure. This was the main friction for Claude Code sessions that want
to participate in the mesh as real peers without running a persistent
background daemon. The daemon-less path makes the entire mesh emission
surface usable out of the box after npm install -g @sym-bot/sym.
No breaking changes. Existing sym observe '<json>' calls continue
to work unchanged when the daemon is running (same IPC fast path).
When the daemon is down, the CLI now falls back to standalone mode
instead of failing.
state-syncframe is now deprecated. CfC hidden states never cross the wire under SVAF (Xu, 2026, Symbolic-Vector Attention Fusion for Collective Intelligence, arXiv:2604.03955, §3.4). Cognitive coupling propagates as CMBs at SVAF Layer 4 only; the per-agent CfC at Layer 6 stays private to each agent._reencodeAndBroadcast()updates the local CfC only; nostate-syncbroadcast.updateContext(text)updates the local CfC only; nostate-syncbroadcast.- The per-handshake
state-syncsend is removed. Handshake exchanges identity, version, and lifecycle role only; cognitive bootstrap happens via the anchor CMB exchange that follows. - Coordinated with
@sym-bot/core0.3.32, which silently drops inboundstate-syncframes at the frame-handler with a deprecation log. - The wire format is preserved (the
state-syncframe type is still parseable for backward compatibility with v0.2.0 / v0.2.1 peers); only the send paths are removed.
If you previously listened for coupling-decision events driven by
state-sync, switch to events emitted by the CMB pipeline
(memoryReceived, cmbAccepted) and read (valence, arousal) from
cmb.fields.mood. The mood field is delivered across domain boundaries
even when SVAF rejects the rest of the CMB (MMP §9.3 protocol guarantee R5).
sym-daemondefault node name is now platform-scoped (sym-daemon-mac/sym-daemon-win/sym-daemon-linux) instead of the hardcodedsym-daemon-win. The hardcoded fallback caused Mac daemons to identify assym-daemon-win, leading to identity collisions and stale~/.sym/nodes/directories on cross-platform development machines.
- Bumped
@sym-bot/coreto^0.3.31to restorecmb-acceptedevent emission incliHostMode. Without this bump,bin/sym-daemon.js'scmb-acceptedlistener never fires undercliHostMode, silently disablingsym subIPC subscribers and the daemon→hosted-agent fanout path.
sym-daemonnow usescliHostMode: true(renamed fromrelayMode). Daemon no longer stores forwarded CMBs — eliminates ~5x duplication on multi-agent hosts.sym recallis now federated: scans~/.sym/nodes/*/meshmem/directly, deduped by CMB key, sorted by recency. Works without the daemon. New--node <name>flag scopes the scan.- Requires
@sym-bot/core@^0.3.31(originally shipped against 0.3.30; 0.3.30 had a regression — see sym-core CHANGELOG).
- High-quality CMBs were silently buried, never promoted to the Review Board, because of two compounding bugs:
lib/llm-reason.jsappended a hardcoded "Return a JSON object with 7 CAT7 fields" suffix to every prompt, with no mention of_meta. This overrode any_meta.founderActioninstructions the agent's role definition (SKILL.md) tried to convey, so the model emitted just the 7 fields and the founderAction signal was lost. Suffix now requests the optional_metatag explicitly:_meta:{founderAction, urgency, reason}and instructs the model to set it per role rules.lib/mesh-agent.jsdetectFounderAction(the fallback when_metais missing) only scannedintent + issueand only matched a hedge-paraphrase vocabulary list (prioritize,monitor,competitive, etc.). Disciplined extraction prompts produce CMBs with concrete verbs likefetch,flag,draft,endorser,arxiv,cite,respond,submit— none of which were in the list, so high-quality CMBs failed to promote. Now scansintent + issue + commitment + mood.text(wider field surface) and the keyword list is expanded with concrete-action verbs, research vocabulary, and stakes-signalling affect words.
Verified: a real research-win arxiv CMB ("Fetch full PDF today. Check author list for endorser candidates...") now correctly promotes via the keyword fallback path. Going forward, disciplined agents using the _meta schema in their prompt suffix will set founderAction explicitly and bypass the keyword fallback entirely.
- Windows terminal popups in CLI provider (
lib/llm-cli.js) —clauderesolved to a.cmdshim that opened visible cmd.exe windows on every spawn. Now usesplatform.resolveClaudeCLI()to get the node binary +cli.jspath directly, pluswindowsHide: trueon the spawn options. - Per-agent env vars not loaded at module-load time (
lib/mesh-agent.js) — agents readSYM_RESEARCH_PROVIDER,SYM_COO_MODEL, etc. at top-of-file constants before theMeshAgentconstructor runs. Added a top-levelloadRelayEnv()IIFE that loads~/.sym/relay.envwhenmesh-agent.jsis first required, so per-agent env overrides resolve correctly.
- Claude Code CLI provider (
provider: 'cli'). Spawnsclaude -p --output-format jsonas a subprocess instead of hitting an HTTP API. Gives every agent the full Claude Code tool surface — Read, Write, Bash, Grep, WebFetch, Skill, etc. — and auto-loadsCLAUDE.md/.claude/settings.json/ project skills from the agent's working directory. Uses local Claude Code auth (no API key needed). Per-call options:model(opus/sonnet/haiku alias or full id),addDirs,allowedTools,permissionMode(defaultbypassPermissions),maxBudget(passed as--max-budget-usd),timeoutMs. Selectable viaprovider: 'cli'per call orSYM_LLM_PROVIDER=cliglobally. Seelib/llm-cli.js. - This is the path for the existing
addDirsparameter that HTTP providers were silently ignoring — agents that already passedaddDirs: [...]get directory access for free as soon as they switch provider.
lib/llm-reason.jsgetProviderConfigandinvokeupdated to dispatchcli/anthropic/openai. CLI provider skips the API-key check (uses local Claude Code auth) and skipswithRetry(subprocess errors aren't typically transient).
MemoryStore._cmbKeynow delegates to@sym-bot/corecmbKey()instead of re-implementing the SHA256-truncate logic. Eliminates duplicated CMB key code that had drifted multiple times. Single source of truth lives insym-core/lib/cmb-encoder.js. The raw-content fallback path remains a direct SHA256 (distinct input space, cannot collide with the field-keyed path).@sym-bot/coredependency bumped to^0.3.29to pick up the newcmbKeyexport and the FNV-1a context encoder fix. The encoder fix restores cross-SDK n-gram embedding parity withsym-core-swiftfor the first time — see@sym-bot/core0.3.29 changelog for the wire-impact details.
- CMB content key algorithm: MD5 → SHA256 (truncated to 32 hex chars).
MemoryStore._cmbKey()now usescrypto.createHash('sha256').digest('hex').slice(0, 32)for both the field-text and raw-content code paths. This duplicated CMB-key logic (separate from@sym-bot/corecmb-encoder.js) is now back in sync. Wire-breaking with respect to dedup against pre-0.3.56 stored CMBs. Coordinated with@sym-bot/core0.3.28 andsym-core-swift0.3.6. @sym-bot/coredependency bumped to^0.3.28.
- Removed accidental self-dependency
@sym-bot/sym: ^0.3.43frompackage.jsondependencies. The package now declares only its real runtime deps (@sym-bot/core,bonjour-service,ws).
- sym-core 0.2.0 — semantic encoder for SVAF evaluation. Paraphrase similarity: 0.31 (n-gram) → 0.69 (semantic). Per-field evaluation quality bounded by encoder quality, not model capacity.
- Catchup via mesh broadcast. Daemon broadcasts
"catchup"message to all peers.MeshAgentlistens for it and triggers immediate domain poll. Replaces the old hosted-agent-only catchup path.
- Handshake:
versionandextensionsfields per MMP v0.2.1 Section 5.2. Handshake now sendsversion: "0.2.1"andextensions: []. - Error frame support per MMP v0.2.1 Section 7.2.
sendError(peerId, code, message, detail)sends protocol-level error frames. Codes 1xxx close connection; 2xxx informational.
- 100% feature parity with sym-swift (Swift SDK). Both SDKs implement all 10 frame types, handshake with version/extensions/e2ePublicKey, error frames, multi-transport per peer, SVAF per-field evaluation, MD5 content-addressable CMB keys, lineage, remix guard, and metrics.
- MeshAgent: every agent is a standalone peer node (MMP v0.2.1). Removed hosted/daemon mode. Every
MeshAgentcreates its ownSymNodewith own identity, transport, coupling engine, and memory store. Coupling is per-node — agents that share another node's identity cannot have independent SVAF weights. sym recall --json— new flag returns full entry objects (source, peerId, CMB fields, lineage) as JSON. Enables sym.day to get real source data from daemon memory.
- 119 tests (was 100). MeshAgent test updated for standalone-only constructor.
- MeshAgent — protocol-level agent lifecycle class. Agents provide
fetchDomain(),reason(),remix(). Protocol handles event-driven remix,canRemix()gate, fingerprint dedup, lineage, silence. No LLM code in SDK. sym metrics— new CLI command exposing protocol-level metrics (CMBs, peers, LLM cost, uptime)--jsonflag forsym status,sym peers,sym metrics— structured output for programmatic consumers
- Remix guard:
remember()with parents now resetshasNewDomainData. Previously a remix counted as new domain data, allowing infinite remix chains from a single observation. - Startup race: relay disconnect handlers directly deleted peers, bypassing multi-transport failover (Section 4.6/5.5). Now only closes the relay transport — Bonjour survives.
- Undefined variables in
_handleRelayPeerLeft(peers,peer) — leftover from refactoring.
- IPC socket moved from
/tmp/sym.sockto~/.sym/daemon.sockper spec Section 4.5.SYM_SOCKETenv var still overrides.
- 100 tests (was 83). Added: remix guard reset, MeshAgent validation, CLI --json, socket path.
The CAT7 container is categories. A record is { categories, metadata }. Nodes on 0.11.0 and
earlier do not read this container name.
The LAN rendezvous is _symrooms._tcp. A node on this version and one on 0.11.0 cannot
discover each other, in either direction, with no error to explain the silence. The word there is
not a concept, it is an address.
The persisted room moved filename. The retired file is not read — one name, no fallback — but
its presence is now announced: the daemon names the file, the room it held, and the command that
restores it. Without that, an upgraded node silently starts in default, loses every peer, and says
nothing.
Identifiers, the SYM_ROOM environment variable, the persisted state file, the module files, and
the beacon. The audience of a record has been room since the two-section record; the discovery
group is the same concept and now carries the same word.
Adopts the renamed core surface (computeCategoryVerdicts, categoryKeyV1,
encryptCategories/decryptCategories, encodeCategory, categoryWeights, categoryDrifts,
categoryVerdicts, categoryParents) and core's fallback removals. Drift arithmetic changes for
stores whose anchors carry no confidence — core no longer invents one.
A pre-boundary record carried its audience under the retired name, so its audience can no longer be established, and core refuses it rather than treating an absent room as a broadcast. A node holding genuine pre-boundary history will now see it refused rather than surfaced. Refused is still not forged: such a record does not touch the forgery counter, because an operator watching that counter at cutover must not see ordinary history in it.