@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 +47 -0
- package/package.json +1 -1
- package/src/plugin/tools.mjs +161 -7
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.
|
|
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",
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
4694
|
-
'
|
|
4695
|
-
'
|
|
4696
|
-
'
|
|
4697
|
-
'
|
|
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) {
|