@twinsearth/w2m-dsh-plugin 1.2.0 → 1.2.2
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9165 -9079
- package/README.md +3 -2
- package/codex/mcp-servers.toml +1 -1
- package/lib/client.js +29 -3
- package/package.json +98 -93
- package/plugin.json +1 -1
- package/src/plugin/council-fleet.mjs +221 -0
- package/src/plugin/dataflow.mjs +72 -0
- package/src/plugin/fleet-chat-route.mjs +58 -2
- package/src/plugin/intents.mjs +137 -0
- package/src/plugin/memory-view.mjs +60 -0
- package/src/plugin/tools.mjs +193 -8
- package/src/relay/fleet-memory.mjs +85 -0
- package/src/relay/fleet-messages.mjs +33 -5
- package/src/relay/server.mjs +42 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fleet memory: an append-only record log with compare-and-swap.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for
|
|
5
|
+
*
|
|
6
|
+
* The fleet memory was read/publish only. Two machines writing meant last-writer-wins over a whole document, so a record
|
|
7
|
+
* added by one could vanish because the other had an older copy. Here the unit of change is a RECORD and the guard is a
|
|
8
|
+
* revision number: a writer says which revision it saw, and a stale one is refused with the current revision and a diff
|
|
9
|
+
* summary instead of being applied on top of something it never read.
|
|
10
|
+
*
|
|
11
|
+
* ## Refusals are values
|
|
12
|
+
*
|
|
13
|
+
* `REVISION_CONFLICT` is a normal answer, not an error: the caller re-reads, decides, and retries. Automatic retry is
|
|
14
|
+
* deliberately NOT implemented here -- retrying in the transport would turn a visible conflict into a silent overwrite.
|
|
15
|
+
*/
|
|
16
|
+
export class FleetMemoryConflict extends Error {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{now?: () => number, maxRecords?: number}} [options]
|
|
20
|
+
*/
|
|
21
|
+
export function createFleetMemory(options = {}) {
|
|
22
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
23
|
+
const maxRecords = Number.isInteger(options.maxRecords) && options.maxRecords > 0 ? options.maxRecords : 10_000;
|
|
24
|
+
/** doc -> { revision, records: [{ at_ms, author_machine_id, text }] } */
|
|
25
|
+
const docs = new Map();
|
|
26
|
+
|
|
27
|
+
const stateOf = (doc) => docs.get(doc) ?? { revision: 0, records: [] };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} doc
|
|
31
|
+
* @returns {{ok: true, revision: number, records: object[], count: number}}
|
|
32
|
+
*/
|
|
33
|
+
function get(doc) {
|
|
34
|
+
const state = stateOf(doc);
|
|
35
|
+
return { ok: true, revision: state.revision, records: state.records.map((r) => ({ ...r })), count: state.records.length };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Append records, guarded by the revision the caller saw.
|
|
40
|
+
*
|
|
41
|
+
* @param {{doc: string, if_revision?: number|null, author_machine_id: string, records: string[]}} input
|
|
42
|
+
* @returns {{ok: true, revision: number, appended: number} |
|
|
43
|
+
* {ok: false, code: string, detail?: object}}
|
|
44
|
+
*/
|
|
45
|
+
function append(input) {
|
|
46
|
+
const doc = typeof input?.doc === 'string' && input.doc.trim() !== '' ? input.doc.trim() : null;
|
|
47
|
+
if (doc === null) return { ok: false, code: 'BAD_DOC' };
|
|
48
|
+
const author = typeof input?.author_machine_id === 'string' && input.author_machine_id !== '' ? input.author_machine_id : null;
|
|
49
|
+
if (author === null) return { ok: false, code: 'BAD_AUTHOR' };
|
|
50
|
+
const incoming = Array.isArray(input?.records) ? input.records : null;
|
|
51
|
+
if (incoming === null) return { ok: false, code: 'NO_RECORDS' };
|
|
52
|
+
const texts = incoming.filter((r) => typeof r === 'string' && r.trim() !== '');
|
|
53
|
+
if (texts.length === 0) return { ok: false, code: 'NO_RECORDS' };
|
|
54
|
+
if (texts.length !== incoming.length) return { ok: false, code: 'BAD_RECORD' };
|
|
55
|
+
|
|
56
|
+
const state = stateOf(doc);
|
|
57
|
+
const seen = Number.isInteger(input?.if_revision) ? input.if_revision : null;
|
|
58
|
+
if (seen !== null && seen !== state.revision) {
|
|
59
|
+
// The diff is what the caller needs to decide: how far ahead the document is, and what it has not read yet.
|
|
60
|
+
const missing = state.records.slice(Math.max(0, seen < 0 ? 0 : seen));
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
code: 'REVISION_CONFLICT',
|
|
64
|
+
detail: {
|
|
65
|
+
if_revision: seen,
|
|
66
|
+
revision: state.revision,
|
|
67
|
+
behind_by: state.revision - seen,
|
|
68
|
+
unread_records: missing.length,
|
|
69
|
+
sample: missing.slice(0, 3).map((r) => ({ author: r.author_machine_id, text: r.text.slice(0, 80) })),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (state.records.length + texts.length > maxRecords) {
|
|
74
|
+
return { ok: false, code: 'MEMORY_FULL', detail: { count: state.records.length, maxRecords } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const at = now();
|
|
78
|
+
const added = texts.map((text) => ({ at_ms: at, author_machine_id: author, text }));
|
|
79
|
+
const next = { revision: state.revision + 1, records: [...state.records, ...added] };
|
|
80
|
+
docs.set(doc, next);
|
|
81
|
+
return { ok: true, revision: next.revision, appended: added.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { get, append, _docs: docs };
|
|
85
|
+
}
|
|
@@ -88,25 +88,46 @@ export function createFleetMessages(options = {}) {
|
|
|
88
88
|
const text = input.text;
|
|
89
89
|
if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
|
|
90
90
|
// Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
|
|
91
|
-
//
|
|
92
|
-
// does not apply -- the escape IS the contract.
|
|
93
|
-
// eslint-disable-next-line no-control-regex
|
|
91
|
+
// eslint-disable-next-line no-control-regex
|
|
94
92
|
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
|
|
95
93
|
const bytes = Buffer.byteLength(text, 'utf8');
|
|
96
94
|
if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
|
|
97
95
|
|
|
96
|
+
// Addressing: 'all' is the group, a machine id is one recipient. ONE row either way -- N copies for N recipients
|
|
97
|
+
// would multiply the byte budget by the fleet size for no extra meaning; visibility is a filter at read time.
|
|
98
|
+
const to = typeof input?.to === 'string' && input.to.trim() !== '' ? input.to.trim() : 'all';
|
|
99
|
+
if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
|
|
100
|
+
// Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
|
|
101
|
+
// exchange inside one thread. Plain fields, validated rather than trusted.
|
|
102
|
+
const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
|
|
103
|
+
? input.thread_id.trim().slice(0, 64)
|
|
104
|
+
: null;
|
|
105
|
+
const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
|
|
106
|
+
|
|
98
107
|
const before = usage();
|
|
99
108
|
const limit = limitBytes();
|
|
100
109
|
if (before.used_24h_bytes + bytes > limit) {
|
|
101
110
|
return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
// A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
|
|
114
|
+
const existing = sessions.get(sessionId) ?? [];
|
|
115
|
+
if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
|
|
116
|
+
return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
|
|
117
|
+
}
|
|
118
|
+
const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
|
|
119
|
+
const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
|
|
120
|
+
|
|
104
121
|
seq += 1;
|
|
105
122
|
const message = {
|
|
106
123
|
message_id: 'msg-' + String(seq).padStart(6, '0'),
|
|
107
124
|
session_id: sessionId,
|
|
108
125
|
fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
|
|
109
126
|
machine_id: machineId,
|
|
127
|
+
to,
|
|
128
|
+
thread_id: threadOf,
|
|
129
|
+
reply_to: replyTo,
|
|
130
|
+
turn,
|
|
110
131
|
text,
|
|
111
132
|
at_ms: now(),
|
|
112
133
|
bytes,
|
|
@@ -122,9 +143,16 @@ export function createFleetMessages(options = {}) {
|
|
|
122
143
|
}
|
|
123
144
|
|
|
124
145
|
/** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
|
|
125
|
-
function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50
|
|
146
|
+
function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
|
|
147
|
+
thread_id: threadId = null, to: toFilter = null } = {}) {
|
|
126
148
|
const rows = sessions.get(sessionId) ?? [];
|
|
127
|
-
|
|
149
|
+
let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
|
|
150
|
+
// A reader sees the group and what was addressed to it, never another machine's private message.
|
|
151
|
+
if (typeof forMachine === 'string' && forMachine !== '') {
|
|
152
|
+
filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
|
|
153
|
+
}
|
|
154
|
+
if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
|
|
155
|
+
if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
|
|
128
156
|
const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
|
|
129
157
|
return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
|
|
130
158
|
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
RabbitState,
|
|
23
23
|
rfc3339,
|
|
24
24
|
} from './state.mjs';
|
|
25
|
-
import { createFleetMessages } from './fleet-messages.mjs';
|
|
25
|
+
import { createFleetMessages } from './fleet-messages.mjs';
|
|
26
|
+
import { createFleetMemory } from './fleet-memory.mjs';
|
|
26
27
|
import { createJoinChallenges } from './join-challenge.mjs';
|
|
27
28
|
import { createAdmission } from './admission.mjs';
|
|
28
29
|
import { Persistence } from './persistence.mjs';
|
|
@@ -1045,6 +1046,7 @@ export class RelayServer {
|
|
|
1045
1046
|
// speak as another. The refusal codes from the store are passed through unchanged (FLEET_QUOTA_EXCEEDED included)
|
|
1046
1047
|
// because a caller that cannot tell "too big" from "over quota" from "not text" cannot tell the operator what to do.
|
|
1047
1048
|
this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
|
|
1049
|
+
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1048
1050
|
|
|
1049
1051
|
if (method === 'POST' && path === '/v1/fleet/messages') {
|
|
1050
1052
|
const body = takeJson();
|
|
@@ -1052,6 +1054,9 @@ export class RelayServer {
|
|
|
1052
1054
|
session_id: body.session_id,
|
|
1053
1055
|
fleet_id: typeof body.fleet_id === 'string' ? body.fleet_id : null,
|
|
1054
1056
|
machine_id: device.machine_id,
|
|
1057
|
+
to: body.to,
|
|
1058
|
+
thread_id: body.thread_id,
|
|
1059
|
+
reply_to: body.reply_to,
|
|
1055
1060
|
text: body.text,
|
|
1056
1061
|
});
|
|
1057
1062
|
if (posted.ok !== true) {
|
|
@@ -1098,6 +1103,11 @@ export class RelayServer {
|
|
|
1098
1103
|
session_id: url.searchParams.get('session_id') ?? '',
|
|
1099
1104
|
since_ms: sinceRaw === null ? null : Number(sinceRaw),
|
|
1100
1105
|
limit: limitRaw === null ? 50 : Number(limitRaw),
|
|
1106
|
+
// Addressing/correlation filters. `for_me=1` is answered from the authenticated device identity, never from a
|
|
1107
|
+
// caller-supplied machine id: a reader must not be able to ask for another machine's private messages.
|
|
1108
|
+
for_machine_id: url.searchParams.get('for_me') === '1' ? device.machine_id : null,
|
|
1109
|
+
thread_id: url.searchParams.get('thread_id'),
|
|
1110
|
+
to: url.searchParams.get('to'),
|
|
1101
1111
|
});
|
|
1102
1112
|
return sendJson(res, 200, {
|
|
1103
1113
|
protocol_version: PROTOCOL_VERSION,
|
|
@@ -1107,6 +1117,37 @@ export class RelayServer {
|
|
|
1107
1117
|
});
|
|
1108
1118
|
}
|
|
1109
1119
|
|
|
1120
|
+
// v1.2.2: shared memory writes. Compare-and-swap on a revision, because two machines appending to one document is
|
|
1121
|
+
// otherwise last-writer-wins over the whole thing -- a record one machine added would vanish because the other had an
|
|
1122
|
+
// older copy. A stale writer gets 409 and the records it has not read; it is NOT retried here, because retrying in
|
|
1123
|
+
// the transport turns a visible conflict into a silent overwrite.
|
|
1124
|
+
if (path.startsWith('/v1/fleet/memory/')) {
|
|
1125
|
+
const doc = decodeURIComponent(path.slice('/v1/fleet/memory/'.length));
|
|
1126
|
+
if (doc === '') throw new ProtocolError('BAD_REQUEST', 'memory doc is required', { method, path });
|
|
1127
|
+
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1128
|
+
if (method === 'GET') {
|
|
1129
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...this.fleetMemory.get(doc) });
|
|
1130
|
+
}
|
|
1131
|
+
if (method === 'PUT') {
|
|
1132
|
+
const body = takeJson();
|
|
1133
|
+
const appended = this.fleetMemory.append({
|
|
1134
|
+
doc,
|
|
1135
|
+
if_revision: Number.isInteger(body.if_revision) ? body.if_revision : null,
|
|
1136
|
+
author_machine_id: device.machine_id,
|
|
1137
|
+
records: Array.isArray(body.records) ? body.records : (typeof body.text === 'string' ? [body.text] : []),
|
|
1138
|
+
});
|
|
1139
|
+
if (appended.ok !== true) {
|
|
1140
|
+
const status = appended.code === 'REVISION_CONFLICT' ? 409 : 400;
|
|
1141
|
+
return sendJson(res, status, {
|
|
1142
|
+
protocol_version: PROTOCOL_VERSION, ok: false,
|
|
1143
|
+
error: { code: appended.code }, detail: appended.detail ?? null,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...appended });
|
|
1147
|
+
}
|
|
1148
|
+
throw new ProtocolError('BAD_REQUEST', 'memory accepts GET or PUT', { method, path });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1110
1151
|
throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
|
|
1111
1152
|
}
|
|
1112
1153
|
|