@twinsearth/w2m-dsh-plugin 1.0.9 → 1.0.10
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 +58 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/plugin/tools.mjs +150 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.0.10] — 2026-10-17
|
|
6
|
+
|
|
7
|
+
**`w2m_memory {"action":"diff"}`: compare this machine's collective memory against the fleet's current blob,
|
|
8
|
+
and write nothing.**
|
|
9
|
+
|
|
10
|
+
### Merge is still not implemented, deliberately
|
|
11
|
+
|
|
12
|
+
The question merge depends on has not been answered:
|
|
13
|
+
|
|
14
|
+
> **When two machines in one fleet assert different things about the same fact, who wins?**
|
|
15
|
+
|
|
16
|
+
That is a product decision about what the fleet's memory *is*, not an implementation detail, and shipping a merge
|
|
17
|
+
before its rule is how a fleet silently loses one machine's memory. So this release ships **the half that needs no
|
|
18
|
+
decision** -- and names the other half as open rather than leaving it implied.
|
|
19
|
+
|
|
20
|
+
### What `diff` answers
|
|
21
|
+
|
|
22
|
+
* **Is my document the newest one?** `same: true/false`, decided by **whole-document hash equality**.
|
|
23
|
+
* **What would I be overwriting?** `preview.local_only` and `preview.fleet_only`.
|
|
24
|
+
* **Is the fleet's document even there?** Three distinct absence states, each named: the fleet never published; the
|
|
25
|
+
pointer names a blob that eviction removed; the blob exists and verified.
|
|
26
|
+
|
|
27
|
+
### Four decisions worth recording
|
|
28
|
+
|
|
29
|
+
**It writes nothing.** No `.orig`, no `.rej`, no silent merge. Comparing is not a licence to edit a document this
|
|
30
|
+
machine owns.
|
|
31
|
+
|
|
32
|
+
**`same` is hash equality, not "no line differences".** Two documents with identical line sets in a different order
|
|
33
|
+
are **not** the same, and callling them the same would hide an order change -- which in a markdown document is a
|
|
34
|
+
structural change.
|
|
35
|
+
|
|
36
|
+
**A set comparison, not a diff.** A line-oriented diff would report "moved" versus "changed" and then want a merge
|
|
37
|
+
policy. **A set comparison cannot resolve anything, which is exactly the property this step requires.**
|
|
38
|
+
|
|
39
|
+
**The fleet copy is re-hashed before it is compared.** Comparing unverified content would launder it: the report
|
|
40
|
+
would give a hash-verified-looking answer about bytes nobody checked. A blob that fails its own name raises
|
|
41
|
+
`W2M_BAD_BLOB` and is never compared.
|
|
42
|
+
|
|
43
|
+
Trailing whitespace is normalised (it is noise); leading whitespace is preserved (it is markdown structure).
|
|
44
|
+
|
|
45
|
+
### Verified
|
|
46
|
+
|
|
47
|
+
The comparison rule is pinned directly: `local_only` / `fleet_only` separate correctly, trailing whitespace creates
|
|
48
|
+
no difference, and leading whitespace does. Full suite **2360 tests, 2346 pass, 0 fail, 14 skip**; pre-flight
|
|
49
|
+
**GREEN, 15 of 15 checks**.
|
|
50
|
+
|
|
51
|
+
### NOT verified
|
|
52
|
+
|
|
53
|
+
The `diff` action **end to end** -- the same gap as `publish`/`pull`/`latest`: type-correct and lint-clean, but
|
|
54
|
+
no test executes it, because that needs a profile with a `fleet-memory.md` and a live relay.
|
|
55
|
+
|
|
56
|
+
### Open: #2b
|
|
57
|
+
|
|
58
|
+
**Append-merge with conflict markers, and the marker's existence is the refusal** -- a document containing markers is
|
|
59
|
+
one a human must not trust until resolved. Last-writer-wins is rejected: it loses data silently, and the project's
|
|
60
|
+
own eviction rule already refuses "delete the only copy of a fact" in spirit. **That recommendation is in
|
|
61
|
+
`FLEET-SYNC-2-DESIGN.md` and is not implemented, because it encodes a rule that is the operator's to choose.**
|
|
62
|
+
|
|
5
63
|
## [1.0.9] — 2026-10-17
|
|
6
64
|
|
|
7
65
|
**Discovery: a machine can now ask the relay which blob its fleet published last, instead of a human relaying a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.10",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
|
|
|
208
208
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
209
209
|
* overrides it at runtime for tests and packagers.
|
|
210
210
|
*/
|
|
211
|
-
const PLUGIN_VERSION = '1.0.
|
|
211
|
+
const PLUGIN_VERSION = '1.0.10';
|
|
212
212
|
|
|
213
213
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
214
214
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -5141,13 +5141,14 @@ export async function apply(ctx, config = {}) {
|
|
|
5141
5141
|
*/
|
|
5142
5142
|
action: {
|
|
5143
5143
|
type: 'string',
|
|
5144
|
-
enum: ['read', 'publish', 'pull', 'latest'],
|
|
5144
|
+
enum: ['read', 'publish', 'pull', 'latest', 'diff'],
|
|
5145
5145
|
description:
|
|
5146
5146
|
'read (default) = answer from memory. publish = upload the curated collective memory '
|
|
5147
5147
|
+ '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. latest = ask the '
|
|
5148
|
-
+ 'relay which blob this fleet published last
|
|
5149
|
-
+ '
|
|
5150
|
-
+ '
|
|
5148
|
+
+ 'relay which blob this fleet published last. pull = fetch one blob by SHA-256 -- or by "latest" -- and '
|
|
5149
|
+
+ 'return its text, re-hashing it. diff = compare the local document against the fleet current blob and '
|
|
5150
|
+
+ 'report what differs, **writing nothing**: merge has no conflict rule yet, so this action deliberately '
|
|
5151
|
+
+ 'resolves nothing. None of these modifies the local file.',
|
|
5151
5152
|
default: 'read',
|
|
5152
5153
|
},
|
|
5153
5154
|
hash: {
|
|
@@ -5464,6 +5465,150 @@ export async function apply(ctx, config = {}) {
|
|
|
5464
5465
|
});
|
|
5465
5466
|
}
|
|
5466
5467
|
|
|
5468
|
+
/**
|
|
5469
|
+
* v1.0.10 (#2a): **compare** this machine's collective memory against the fleet's current blob. Writes nothing.
|
|
5470
|
+
*
|
|
5471
|
+
* ## Why this exists before merge does
|
|
5472
|
+
*
|
|
5473
|
+
* "Merge" needs a conflict rule -- *when two machines assert different facts, who wins?* -- and that is a
|
|
5474
|
+
* product decision, not an implementation detail. Shipping a merge before its rule is how a fleet silently
|
|
5475
|
+
* loses one machine's memory.
|
|
5476
|
+
*
|
|
5477
|
+
* What needs no decision is the half that **prevents harm**: telling a caller whether their local document is
|
|
5478
|
+
* the newest one, and what would be lost if they published over the fleet's copy. That is this action.
|
|
5479
|
+
*
|
|
5480
|
+
* ## Why a set comparison instead of a diff
|
|
5481
|
+
*
|
|
5482
|
+
* `same` is decided by **whole-document hash equality**, not by the absence of line differences: two documents
|
|
5483
|
+
* with identical line sets in a different order are not the same, and reporting them as the same would hide an
|
|
5484
|
+
* order change. The line sets are then reported as `local_only` / `fleet_only` -- deliberately *not* a diff,
|
|
5485
|
+
* because a diff would report "moved" versus "changed" and then want a merge policy. **A set comparison cannot
|
|
5486
|
+
* resolve anything, which is the property this step needs.**
|
|
5487
|
+
*
|
|
5488
|
+
* ## Why it re-hashes the fleet copy
|
|
5489
|
+
*
|
|
5490
|
+
* Comparing unverified content would launder it: the report would give a hash-verified-looking answer about
|
|
5491
|
+
* bytes nobody checked. A blob that fails its own name is reported as unverified, never compared.
|
|
5492
|
+
*/
|
|
5493
|
+
if (typeof args?.action === 'string' && args.action === 'diff') {
|
|
5494
|
+
const file = collectivePath();
|
|
5495
|
+
if (file === null) {
|
|
5496
|
+
throw new W2MError('W2M_CONFIG', 'W2M_CONFIG: there is no profile to compare collective memory from', {
|
|
5497
|
+
hint: 'the collective layer lives beside the profile that mounted this plugin',
|
|
5498
|
+
});
|
|
5499
|
+
}
|
|
5500
|
+
let localBytes;
|
|
5501
|
+
try {
|
|
5502
|
+
localBytes = await fs.readFile(file);
|
|
5503
|
+
} catch {
|
|
5504
|
+
throw new W2MError('W2M_NOT_FOUND', `W2M_NOT_FOUND: no collective memory to compare at ${file}`, {
|
|
5505
|
+
hint: 'write measured facts to it first; this action compares, it does not create',
|
|
5506
|
+
});
|
|
5507
|
+
}
|
|
5508
|
+
const localHash = createHash('sha256').update(localBytes).digest('hex');
|
|
5509
|
+
const base = requireRabbit(cfg);
|
|
5510
|
+
const token = await resolveToken(cfg);
|
|
5511
|
+
|
|
5512
|
+
const pointer = await request({
|
|
5513
|
+
rabbitUrl: base,
|
|
5514
|
+
token,
|
|
5515
|
+
pathname: '/v1/fleet/blobs/latest',
|
|
5516
|
+
signal: exec?.signal,
|
|
5517
|
+
});
|
|
5518
|
+
if (pointer.status === 404) {
|
|
5519
|
+
// No fleet document yet: nothing to differ from. Reported as a state, not as an error.
|
|
5520
|
+
return JSON.stringify({
|
|
5521
|
+
ok: true,
|
|
5522
|
+
same: false,
|
|
5523
|
+
local: { hash: localHash, bytes: localBytes.length, file },
|
|
5524
|
+
fleet: { present: false, reason: 'this fleet has published no blob yet' },
|
|
5525
|
+
merged: false,
|
|
5526
|
+
wrote_anything: false,
|
|
5527
|
+
});
|
|
5528
|
+
}
|
|
5529
|
+
if (pointer.status !== 200) {
|
|
5530
|
+
throw new W2MError(
|
|
5531
|
+
'W2M_RABBIT_REFUSED',
|
|
5532
|
+
`W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/latest answered HTTP ${pointer.status}`,
|
|
5533
|
+
{ http_status: pointer.status, detail: pointer.error ?? null },
|
|
5534
|
+
);
|
|
5535
|
+
}
|
|
5536
|
+
const fleetHash = String(pointer.json?.hash ?? '');
|
|
5537
|
+
const fleetRes = await request({
|
|
5538
|
+
rabbitUrl: base,
|
|
5539
|
+
token,
|
|
5540
|
+
pathname: `/v1/fleet/blobs/${fleetHash}`,
|
|
5541
|
+
accept: 'application/octet-stream',
|
|
5542
|
+
signal: exec?.signal,
|
|
5543
|
+
});
|
|
5544
|
+
if (fleetRes.status === 404) {
|
|
5545
|
+
// The pointer is a hint (see #3): eviction can take the document it names.
|
|
5546
|
+
return JSON.stringify({
|
|
5547
|
+
ok: true,
|
|
5548
|
+
same: false,
|
|
5549
|
+
local: { hash: localHash, bytes: localBytes.length, file },
|
|
5550
|
+
fleet: {
|
|
5551
|
+
present: false,
|
|
5552
|
+
pointed_at: fleetHash,
|
|
5553
|
+
reason: 'the pointer names a blob that no longer exists (oldest-first eviction)',
|
|
5554
|
+
},
|
|
5555
|
+
merged: false,
|
|
5556
|
+
wrote_anything: false,
|
|
5557
|
+
});
|
|
5558
|
+
}
|
|
5559
|
+
if (fleetRes.status !== 200) {
|
|
5560
|
+
throw new W2MError(
|
|
5561
|
+
'W2M_RABBIT_REFUSED',
|
|
5562
|
+
`W2M_RABBIT_REFUSED: GET /v1/fleet/blobs/${fleetHash} answered HTTP ${fleetRes.status}`,
|
|
5563
|
+
{ http_status: fleetRes.status, detail: fleetRes.error ?? null },
|
|
5564
|
+
);
|
|
5565
|
+
}
|
|
5566
|
+
const fleetText = fleetRes.text ?? '';
|
|
5567
|
+
const fleetActual = createHash('sha256').update(fleetText, 'utf8').digest('hex');
|
|
5568
|
+
if (fleetActual !== fleetHash) {
|
|
5569
|
+
throw new W2MError(
|
|
5570
|
+
'W2M_BAD_BLOB',
|
|
5571
|
+
`W2M_BAD_BLOB: the fleet blob ${fleetHash} hashes to ${fleetActual} instead`,
|
|
5572
|
+
{
|
|
5573
|
+
hint: 'refusing to compare unverified content -- a comparison over bytes nobody checked would '
|
|
5574
|
+
+ 'launder them',
|
|
5575
|
+
expected: fleetHash,
|
|
5576
|
+
actual: fleetActual,
|
|
5577
|
+
},
|
|
5578
|
+
);
|
|
5579
|
+
}
|
|
5580
|
+
|
|
5581
|
+
const localText = localBytes.toString('utf8');
|
|
5582
|
+
// Trailing whitespace only: leading whitespace is markdown structure, not noise.
|
|
5583
|
+
const norm = (text) => new Set(text.split('\n').map((line) => line.replace(/\s+$/, '')));
|
|
5584
|
+
const localLines = norm(localText);
|
|
5585
|
+
const fleetLines = norm(fleetText);
|
|
5586
|
+
const localOnly = [...localLines].filter((line) => !fleetLines.has(line));
|
|
5587
|
+
const fleetOnly = [...fleetLines].filter((line) => !localLines.has(line));
|
|
5588
|
+
return JSON.stringify({
|
|
5589
|
+
ok: true,
|
|
5590
|
+
same: localHash === fleetHash,
|
|
5591
|
+
local: { hash: localHash, bytes: localBytes.length, lines: localLines.size, file },
|
|
5592
|
+
fleet: {
|
|
5593
|
+
present: true,
|
|
5594
|
+
hash: fleetHash,
|
|
5595
|
+
bytes: Buffer.byteLength(fleetText, 'utf8'),
|
|
5596
|
+
lines: fleetLines.size,
|
|
5597
|
+
published_at: pointer.json?.published_at ?? null,
|
|
5598
|
+
published_by: pointer.json?.published_by ?? null,
|
|
5599
|
+
},
|
|
5600
|
+
differing_lines: localOnly.length + fleetOnly.length,
|
|
5601
|
+
preview: {
|
|
5602
|
+
local_only: localOnly.slice(0, 20),
|
|
5603
|
+
fleet_only: fleetOnly.slice(0, 20),
|
|
5604
|
+
truncated: localOnly.length > 20 || fleetOnly.length > 20,
|
|
5605
|
+
},
|
|
5606
|
+
merged: false,
|
|
5607
|
+
wrote_anything: false,
|
|
5608
|
+
note: 'a comparison only. Merge has no conflict rule yet, so nothing is resolved and nothing is written.',
|
|
5609
|
+
});
|
|
5610
|
+
}
|
|
5611
|
+
|
|
5467
5612
|
const limitRaw = Number(args?.limit);
|
|
5468
5613
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 500) : 20;
|
|
5469
5614
|
|