@twinsearth/w2m-dsh-plugin 0.9.6 → 0.9.7

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,53 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.9.7] — 2026-10-14
8
+
9
+ **`w2m_memory` gains the collective layer and the one write a machine may not make for itself.**
10
+
11
+ ### `scope: "fleet"` — the collective memory
12
+
13
+ Reads the curated `fleet-memory.md` that §3.2 of the design puts on the archive host, beside the profile
14
+ that mounted this plugin. It returns the document **as text, with its section list**, and does not parse
15
+ it into facts — deliberately. Hand-written prose has no schema, so a parser would silently drop whatever
16
+ shape it did not recognise, and what it dropped would be exactly what a human judged worth writing down.
17
+ The section list is there so a reader can see what the collective memory covers before paying for all of
18
+ it.
19
+
20
+ An absent file is answered honestly rather than as an empty document: `MEMORY_NO_COLLECTIVE`, with the
21
+ path that was looked at. *"Nothing has been curated yet"* and *"the memory is empty"* are different
22
+ facts, and only one of them means someone should start writing.
23
+
24
+ ### `record` — a conclusion, which only a reader may assert
25
+
26
+ `memoryRecord` always emits `conclusion: null`, because a machine that invented one would be asserting an
27
+ interpretation it cannot have. This is the path by which a reader supplies it: the conclusion is appended
28
+ as a **new record naming the task it is about**, never written into the existing line.
29
+
30
+ Append-only is the point. In-place editing would break the property that makes the log trustworthy — what
31
+ was written is what happened, and an amendment is a later statement *about* it — and it would destroy the
32
+ original text, which is the thing a reader most needs when a conclusion turns out to be wrong.
33
+
34
+ ### A defect this found in its own first draft
35
+
36
+ `collectivePath()` was `path.join(resolveProfileDir(cfg), COLLECTIVE_FILE)`. `resolveProfileDir`
37
+ legitimately returns `null` — a bare test context, or a host whose layout it cannot read — and the first
38
+ version passed that straight into `path.join`, which threw `ERR_INVALID_ARG_TYPE`. **A read tool that
39
+ crashes on a missing directory is worse than one that says the directory is missing:** the caller was
40
+ asking a question, and an exception is not an answer. It now resolves to `null`, reports
41
+ `MEMORY_NO_COLLECTIVE`, and says which of the two reasons applies.
42
+
43
+ ### Verified
44
+
45
+ - `eslint .` — 0 errors.
46
+ - The contract suites — `memory`, `tools`, `tool-cards`, `claims`, `loader-entry`, `update-wiring`,
47
+ `p2p-plugin` — **230 pass, 0 fail, 11 platform skips**.
48
+ - Every scope exercised against a real temporary profile: `fleet` found the seeded document and listed
49
+ its five sections; `record` wrote a conclusion against a task and reported that no task existed when
50
+ none did; an absent collective file produced `MEMORY_NO_COLLECTIVE` rather than a throw.
51
+ - The tool count is unchanged at nine, so this release moves no contract: the same nine tools, two of them
52
+ wider.
53
+
7
54
  ## [0.9.6] — 2026-10-14
8
55
 
9
56
  **Individual memory: the fleet can now answer "what did we already try on this machine, and what happened?" — a question nothing in it could answer before.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -97,7 +97,7 @@ import path from 'node:path';
97
97
  // file here: the reader carries the rules that matter -- newest-first by file position, a malformed line
98
98
  // skipped rather than fatal, and the rotated generation reachable -- and a second implementation would
99
99
  // drift from the writer's shape without anything failing.
100
- import { readMemorySync } from '../agent/memory.mjs';
100
+ import { appendMemory, readMemorySync } from '../agent/memory.mjs';
101
101
 
102
102
  // The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
103
103
  // than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
185
185
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
186
186
  * overrides it at runtime for tests and packagers.
187
187
  */
188
- const PLUGIN_VERSION = '0.9.6';
188
+ const PLUGIN_VERSION = '0.9.7';
189
189
 
190
190
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
191
191
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -4687,15 +4687,48 @@ export async function apply(ctx, config = {}) {
4687
4687
  * fleet's roster still comes from the relay, so the answer distinguishes "no such machine" from "that
4688
4688
  * machine keeps its own memory and this build cannot fetch it".
4689
4689
  */
4690
+ /**
4691
+ * The collective layer's filename, beside the profile that mounted this plugin.
4692
+ *
4693
+ * §3.2 of the design puts curated fleet knowledge on the archive host. This is the read half of it, and
4694
+ * it deliberately reads a *markdown document* rather than a store: the design's own argument is that a
4695
+ * memory which is merely a log is one nobody reads, so the collective layer is written for a person and
4696
+ * this tool returns it as text rather than pretending to have parsed it into facts. A parser over
4697
+ * hand-written prose would silently drop whatever it did not recognise, and the dropped part would be
4698
+ * exactly the part a human thought was worth writing down.
4699
+ */
4700
+ const COLLECTIVE_FILE = 'fleet-memory.md';
4701
+ /**
4702
+ * The collective document's path, or `null` when there is no profile to resolve it against.
4703
+ *
4704
+ * `resolveProfileDir` legitimately returns `null` -- a bare test context, or a host whose layout it
4705
+ * cannot read -- and the first version of this passed that straight into `path.join`, which threw
4706
+ * `ERR_INVALID_ARG_TYPE`. A read tool that crashes on a missing directory is worse than one that says
4707
+ * the directory is missing: the caller was asking a question, and an exception is not an answer.
4708
+ */
4709
+ const collectivePath = () => {
4710
+ const dir = resolveProfileDir(cfg);
4711
+ return typeof dir === 'string' && dir !== '' ? path.join(dir, COLLECTIVE_FILE) : null;
4712
+ };
4713
+
4690
4714
  ctx.tools.register(defineTool({
4691
4715
  name: 'w2m_memory',
4692
4716
  description:
4693
- 'Ask what this machine has already done: its individual memory, one line per finished task, ' +
4694
- 'recording the command, exit code, duration, transport and the tail of stdout/stderr. Use it ' +
4695
- 'before re-running work to find out whether it has been tried and what happened. The relay keeps ' +
4696
- 'only hashes, so this local file is the only place a command\'s output survives. Reads only this ' +
4697
- 'machine\'s memory; for another machine it reports where that memory lives rather than guessing.',
4717
+ 'Ask what the fleet already knows and what this machine has already done. scope="local" reads ' +
4718
+ 'this machine\'s individual memory: one line per finished task with the command, exit code, ' +
4719
+ 'duration, transport and the tails of stdout/stderr. scope="fleet" reads the curated collective ' +
4720
+ 'memory: measured facts with their dates, decisions with their reasoning, and open problems. The ' +
4721
+ 'relay keeps only hashes, so these files are the only place a command\'s output survives. Use it ' +
4722
+ 'before re-running work -- it is what turns a dozen rediscovery dispatches into one lookup.',
4698
4723
  parameters: {
4724
+ scope: {
4725
+ type: 'string',
4726
+ enum: ['local', 'fleet'],
4727
+ description:
4728
+ 'local (default) = what this machine did; fleet = what the fleet knows, from the curated ' +
4729
+ 'collective memory.',
4730
+ default: 'local',
4731
+ },
4699
4732
  machine: {
4700
4733
  type: 'string',
4701
4734
  description:
@@ -4714,6 +4747,13 @@ export async function apply(ctx, config = {}) {
4714
4747
  'memory question about anything older than the current file needs this.',
4715
4748
  default: false,
4716
4749
  },
4750
+ record: {
4751
+ type: 'string',
4752
+ description:
4753
+ 'Record a conclusion against the most recent task: what its result meant. This is the only ' +
4754
+ 'field a machine is not allowed to write itself -- the machine stores the fact, a reader ' +
4755
+ 'asserts the meaning. Written as a new record so nothing already written is rewritten.',
4756
+ },
4717
4757
  },
4718
4758
  output: jsonOutput,
4719
4759
  async execute(args, exec) {
@@ -4726,6 +4766,120 @@ export async function apply(ctx, config = {}) {
4726
4766
  const self = identity?.machine_name ?? null;
4727
4767
  const selfId = identity?.machine_id ?? null;
4728
4768
 
4769
+ /**
4770
+ * The collective layer, returned as the document it is.
4771
+ *
4772
+ * No parsing, on purpose. The file is written for a person -- facts with their evidence and the date
4773
+ * they were measured, decisions with the reasoning that produced them -- and a parser over prose
4774
+ * would silently drop whatever shape it did not expect. What it dropped would be exactly what a
4775
+ * human judged worth writing down, so the honest answer is the text plus where it came from.
4776
+ */
4777
+ if (args?.scope === 'fleet') {
4778
+ const file = collectivePath();
4779
+ let text = null;
4780
+ let error = null;
4781
+ if (file === null) {
4782
+ error = 'MEMORY_NO_COLLECTIVE';
4783
+ } else {
4784
+ try {
4785
+ text = await fs.readFile(file, 'utf8');
4786
+ } catch (readError) {
4787
+ error = readError?.code === 'ENOENT' ? 'MEMORY_NO_COLLECTIVE' : `MEMORY_READ_FAILED: ${readError?.code}`;
4788
+ }
4789
+ }
4790
+ const headings = text === null
4791
+ ? []
4792
+ : text.split('\n').filter((line) => /^#{1,3} /.test(line)).map((line) => line.replace(/^#+\s*/, '').trim());
4793
+ return JSON.stringify(
4794
+ {
4795
+ ok: true,
4796
+ scope: 'fleet',
4797
+ path: file,
4798
+ found: text !== null,
4799
+ bytes: text === null ? null : Buffer.byteLength(text, 'utf8'),
4800
+ // The section list is what makes a long document usable: a reader can see what the collective
4801
+ // memory covers without paying for the whole of it first.
4802
+ sections: headings,
4803
+ body: text,
4804
+ ...(error === null ? {} : { reason_code: error }),
4805
+ notes: [
4806
+ error === 'MEMORY_NO_COLLECTIVE'
4807
+ ? (file === null
4808
+ ? 'no profile directory could be resolved, so the collective memory has no known location'
4809
+ : `no collective memory at ${file}; it is written by hand on the archive host, and its absence means nothing has been curated there yet`)
4810
+ : null,
4811
+ 'this document is curated, not generated: every fact in it should carry the date it was measured and how',
4812
+ ].filter((n) => n !== null),
4813
+ },
4814
+ null,
4815
+ 2,
4816
+ );
4817
+ }
4818
+
4819
+ /**
4820
+ * Recording a conclusion.
4821
+ *
4822
+ * The one field a machine may not write for itself. `memoryRecord` always emits `conclusion: null`
4823
+ * because a machine that invented one would be asserting an interpretation it cannot have; this is
4824
+ * the path by which a reader -- a person, or the agent acting on their instruction -- supplies it.
4825
+ *
4826
+ * Appended as a new record rather than by rewriting the existing line. The log is append-only, and
4827
+ * in-place editing would break the property that makes it trustworthy: what was written is what
4828
+ * happened, and an amendment is a later statement about it. Rewriting would also lose the original
4829
+ * text, which is the thing a reader most needs when a conclusion turns out to be wrong.
4830
+ */
4831
+ const conclusionText = typeof args?.record === 'string' && args.record.trim() !== '' ? args.record.trim() : null;
4832
+ if (conclusionText !== null) {
4833
+ const stateDirForRecord = cfg.stateDir ?? null;
4834
+ if (stateDirForRecord === null) {
4835
+ return JSON.stringify(
4836
+ {
4837
+ ok: false,
4838
+ scope: 'local',
4839
+ reason_code: 'MEMORY_NO_STATE_DIR',
4840
+ detail: 'this plugin has no stateDir configured, so a conclusion has nowhere to be written',
4841
+ },
4842
+ null,
4843
+ 2,
4844
+ );
4845
+ }
4846
+ const recent = readMemorySync(stateDirForRecord, { limit: 1 });
4847
+ const subject = recent.records[0] ?? null;
4848
+ const written = appendMemory(stateDirForRecord, {
4849
+ at: new Date().toISOString(),
4850
+ kind: 'conclusion',
4851
+ task_id: subject?.task_id ?? null,
4852
+ machine_id: selfId,
4853
+ machine_name: self,
4854
+ // The conclusion is about the record named by `task_id`; the fields below repeat enough of it
4855
+ // that a reader does not have to join two lines to know what is being talked about.
4856
+ about: subject === null
4857
+ ? null
4858
+ : { argv: subject.argv ?? null, exit_code: subject.exit_code ?? null, at: subject.at ?? null },
4859
+ conclusion: conclusionText,
4860
+ tags: [],
4861
+ });
4862
+ return JSON.stringify(
4863
+ {
4864
+ ok: written.ok === true,
4865
+ scope: 'local',
4866
+ recorded: written.ok === true,
4867
+ task_id: subject?.task_id ?? null,
4868
+ conclusion: conclusionText,
4869
+ path: written.ok === true ? written.path : null,
4870
+ ...(written.ok === true ? {} : { reason_code: written.error }),
4871
+ notes: [
4872
+ subject === null
4873
+ ? 'no task has been recorded here yet, so this conclusion names no task; it is still kept, with about: null'
4874
+ : `recorded against task ${subject.task_id}`,
4875
+ 'appended, never rewritten: the original record is left as it was written',
4876
+ ],
4877
+ },
4878
+ null,
4879
+ 2,
4880
+ );
4881
+ }
4882
+
4729
4883
  // A query about another machine is answered honestly rather than with an empty list, which would
4730
4884
  // read as "that machine has done nothing" -- the opposite conclusion from "this build cannot see".
4731
4885
  if (requested !== null && requested !== self && requested !== selfId) {