@twinsearth/w2m-dsh-plugin 1.1.19 → 1.1.20
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 +38 -0
- package/README.md +41 -0
- package/codex/mcp-servers.toml +1 -1
- package/lib/client.js +850 -819
- package/package.json +9 -6
- package/plugin.json +1 -1
- package/src/plugin/fleet-chat-route.mjs +221 -120
- package/src/plugin/fleet-target.mjs.bak-arch2 +812 -0
- package/src/plugin/fleet-target.mjs.bak-archive +812 -0
- package/src/plugin/session-sync.mjs +102 -0
- package/src/plugin/session-watch.mjs +127 -0
- package/src/plugin/tools.mjs +7148 -7140
- package/src/plugin/tools.mjs.bak-c7 +7029 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision layer for mirroring a machine's own prompts into the fleet panel.
|
|
3
|
+
*
|
|
4
|
+
* Reading the transcript and posting to the relay are someone else's job; what belongs here is the two things that are
|
|
5
|
+
* easy to get wrong and impossible to see in a smoke test:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Only the new messages.** The transcript grows while the session runs, and a naive reader republishes the whole
|
|
8
|
+
* history on every tick. This keeps a high-water mark and returns only what appeared after it.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Never echo our own broadcasts back.** Every machine reads its own transcript, so the panel would show each local
|
|
11
|
+
* message once as "from session" and again as "remote" without a rule saying which arrivals are ours.
|
|
12
|
+
*
|
|
13
|
+
* The messages carry \`origin: 'session'\` so the panel can colour them differently from messages typed into the fleet chat
|
|
14
|
+
* box, and \`conversation_key\` so only machines in the same project conversation show them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{conversationKey: string, machineId: string, machineName: string|null}} identity
|
|
19
|
+
*/
|
|
20
|
+
export function createSessionSync(identity) {
|
|
21
|
+
if (typeof identity?.conversationKey !== 'string' || identity.conversationKey === '') {
|
|
22
|
+
throw new TypeError('conversationKey is required');
|
|
23
|
+
}
|
|
24
|
+
if (typeof identity?.machineId !== 'string' || identity.machineId === '') {
|
|
25
|
+
throw new TypeError('machineId is required');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** How far into the message list we have already published. */
|
|
29
|
+
let highWater = 0;
|
|
30
|
+
/** Fingerprints of what we published, so a transcript that is re-read from the start cannot duplicate. */
|
|
31
|
+
const published = new Set();
|
|
32
|
+
|
|
33
|
+
const fingerprint = (message) => (message.at_ms === null ? 't:' : 'a:' + message.at_ms) + '|' + message.text;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Take the messages read from the transcript and return the ones that still have to be broadcast.
|
|
37
|
+
*
|
|
38
|
+
* @param {{at_ms: number|null, text: string}[]} messages oldest first, as the reader returned them
|
|
39
|
+
* @returns {{conversation_key: string, machine_id: string, machine_name: string|null, origin: 'session',
|
|
40
|
+
* at_ms: number|null, text: string}[]}
|
|
41
|
+
*/
|
|
42
|
+
function pending(messages) {
|
|
43
|
+
if (!Array.isArray(messages)) return [];
|
|
44
|
+
const fresh = messages.slice(highWater);
|
|
45
|
+
highWater = messages.length;
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const message of fresh) {
|
|
48
|
+
if (message === null || typeof message.text !== 'string' || message.text.trim() === '') continue;
|
|
49
|
+
const key = fingerprint(message);
|
|
50
|
+
if (published.has(key)) continue;
|
|
51
|
+
published.add(key);
|
|
52
|
+
out.push({
|
|
53
|
+
conversation_key: identity.conversationKey,
|
|
54
|
+
machine_id: identity.machineId,
|
|
55
|
+
machine_name: identity.machineName ?? null,
|
|
56
|
+
origin: 'session',
|
|
57
|
+
at_ms: message.at_ms ?? null,
|
|
58
|
+
text: message.text,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { pending, highWater: () => highWater };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether an arriving fleet message should be shown as "remote".
|
|
69
|
+
*
|
|
70
|
+
* A message this machine published comes back over the relay; showing it as remote would duplicate every local prompt in
|
|
71
|
+
* the panel, which is the failure this rule exists for.
|
|
72
|
+
*
|
|
73
|
+
* @param {{machine_id?: string, origin?: string}} message
|
|
74
|
+
* @param {string} machineId this machine
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
export function isRemoteBroadcast(message, machineId) {
|
|
78
|
+
if (message === null || typeof message !== 'object') return false;
|
|
79
|
+
if (typeof message.machine_id !== 'string') return false;
|
|
80
|
+
return message.machine_id !== machineId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The colour class the panel uses: 'session' (this machine's prompt), 'chat' (typed into the fleet box here) or 'remote'.
|
|
85
|
+
*
|
|
86
|
+
* @param {{machine_id?: string, origin?: string}} message
|
|
87
|
+
* @param {string} machineId
|
|
88
|
+
* @returns {'session'|'chat'|'remote'}
|
|
89
|
+
*/
|
|
90
|
+
export function displayKind(message, machineId) {
|
|
91
|
+
if (isRemoteBroadcast(message, machineId)) return 'remote';
|
|
92
|
+
return message?.origin === 'session' ? 'session' : 'chat';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A stable index into the palette for one machine, so a machine keeps its colour across sessions and restarts. */
|
|
96
|
+
export function avatarColorIndex(machineId, paletteSize) {
|
|
97
|
+
const size = Number.isInteger(paletteSize) && paletteSize > 0 ? paletteSize : 8;
|
|
98
|
+
const s = String(machineId ?? '');
|
|
99
|
+
let h = 0;
|
|
100
|
+
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
101
|
+
return h % size;
|
|
102
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the user's own messages out of DSH's session transcript, without touching it.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for
|
|
5
|
+
*
|
|
6
|
+
* The operator wants the fleet chat panel to show what each machine's owner typed into DSH **in that session**, and to
|
|
7
|
+
* leave the session itself alone: no injection, no rewriting, no added rows. So this module only READS.
|
|
8
|
+
*
|
|
9
|
+
* ## The transcript is a concatenation of zstd frames
|
|
10
|
+
*
|
|
11
|
+
* DSH appends to \`session.v4.jsonl.zstd\` as it goes, which means one file holds many zstd frames. Node's
|
|
12
|
+
* \`zstdDecompressSync\` stops at the end of the first frame -- measured: a 37 KB file that plainly holds a session
|
|
13
|
+
* decodes to 187 bytes -- so the file is split on the zstd magic number and each frame is decoded on its own.
|
|
14
|
+
*
|
|
15
|
+
* ## What is extracted, and what is deliberately not
|
|
16
|
+
*
|
|
17
|
+
* Only messages with role \`user\`: that is what the operator asked to broadcast ("只同步用户发给 DSH 的话"). Assistant
|
|
18
|
+
* replies, tool calls and tool results are not read out -- they are the widest part of a session and the operator did not
|
|
19
|
+
* ask for them.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
23
|
+
|
|
24
|
+
/** The zstd frame magic number, little-endian bytes 28 B5 2F FD. */
|
|
25
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Buffer} buffer the concatenated frames
|
|
29
|
+
* @returns {Buffer[]} one slice per frame, in file order
|
|
30
|
+
*/
|
|
31
|
+
export function splitZstdFrames(buffer) {
|
|
32
|
+
const starts = [];
|
|
33
|
+
let at = buffer.indexOf(ZSTD_MAGIC, 0);
|
|
34
|
+
while (at !== -1) { starts.push(at); at = buffer.indexOf(ZSTD_MAGIC, at + 1); }
|
|
35
|
+
if (starts.length === 0) return [];
|
|
36
|
+
return starts.map((start, i) => buffer.subarray(start, i + 1 < starts.length ? starts[i + 1] : buffer.length));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Decode every frame and parse the JSONL events they contain.
|
|
41
|
+
*
|
|
42
|
+
* A frame that fails to decode is skipped rather than aborting the read: the newest frame can be half-written while the
|
|
43
|
+
* session is live, and one truncated tail must not hide the whole history.
|
|
44
|
+
*
|
|
45
|
+
* @param {Buffer} buffer
|
|
46
|
+
* @returns {object[]} parsed events, oldest first
|
|
47
|
+
*/
|
|
48
|
+
export function decodeTranscript(buffer) {
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const frame of splitZstdFrames(buffer)) {
|
|
51
|
+
let text;
|
|
52
|
+
try { text = zstdDecompressSync(frame).toString('utf8'); } catch { continue; }
|
|
53
|
+
for (const line of text.split('\n')) {
|
|
54
|
+
if (line === '') continue;
|
|
55
|
+
try { events.push(JSON.parse(line)); } catch { /* a partial trailing line is expected in a live file */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return events;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The text of one user message, or null when the event is not one. */
|
|
62
|
+
function userTextOf(event) {
|
|
63
|
+
if (event === null || typeof event !== 'object') return null;
|
|
64
|
+
const role = event.role ?? event.type ?? null;
|
|
65
|
+
if (role !== 'user' && role !== 'prompt') return null;
|
|
66
|
+
const payload = event.content ?? event.text ?? event.message ?? null;
|
|
67
|
+
if (typeof payload === 'string') return payload;
|
|
68
|
+
if (Array.isArray(payload)) {
|
|
69
|
+
const text = payload.filter((p) => p && (p.type === 'text' || typeof p.text === 'string')).map((p) => p.text ?? '').join('');
|
|
70
|
+
return text === '' ? null : text;
|
|
71
|
+
}
|
|
72
|
+
if (payload !== null && typeof payload === 'object' && typeof payload.text === 'string') return payload.text;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Only the user's own messages: that is the operator's scope, and it is the narrowest thing that answers the request.
|
|
78
|
+
*
|
|
79
|
+
* @param {object[]} events
|
|
80
|
+
* @returns {{at_ms: number|null, text: string}[]}
|
|
81
|
+
*/
|
|
82
|
+
export function extractUserMessages(events) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const event of events) {
|
|
85
|
+
if (event === null || typeof event !== 'object') continue;
|
|
86
|
+
const text = userTextOf(event);
|
|
87
|
+
if (text === null || text.trim() === '') continue;
|
|
88
|
+
const at = Number.isFinite(event.createdAt) ? event.createdAt : (Number.isFinite(event.at_ms) ? event.at_ms : null);
|
|
89
|
+
out.push({ at_ms: at, text });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Which conversation a machine's session belongs to.
|
|
96
|
+
*
|
|
97
|
+
* The operator's rule: **account + project directory name**, so two machines with the same project name are the same
|
|
98
|
+
* conversation and nothing has to be configured. The full path is deliberately not used -- \`E:\\GPT\` and
|
|
99
|
+
* \`/Users/fang/GPT\` are the same project, and keying on the path would split them.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} accountFingerprint
|
|
102
|
+
* @param {string} cwd absolute working directory of the session
|
|
103
|
+
* @returns {string} 64 hex characters
|
|
104
|
+
*/
|
|
105
|
+
export function conversationKey(accountFingerprint, cwd) {
|
|
106
|
+
if (typeof accountFingerprint !== 'string' || accountFingerprint === '') throw new TypeError('accountFingerprint is required');
|
|
107
|
+
if (typeof cwd !== 'string' || cwd === '') throw new TypeError('cwd is required');
|
|
108
|
+
const normalised = cwd.replace(/[\\/]+$/, '');
|
|
109
|
+
const parts = normalised.split(/[\\/]/).filter(Boolean);
|
|
110
|
+
const project = (parts.length > 0 ? parts[parts.length - 1] : normalised).toLowerCase();
|
|
111
|
+
return createHash('sha256').update('w2m-conversation-v1').update('\u0000').update(accountFingerprint).update('\u0000').update(project).digest('hex');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The directory name DSH uses for a cwd, so the right transcript can be found without guessing.
|
|
116
|
+
*
|
|
117
|
+
* Measured on this machine: \`E:\\GPT\` is stored as \`--E-GPT--\`. The rule below reproduces it: drop the drive colon,
|
|
118
|
+
* turn separators into \`-\`, wrap in \`--\`.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} cwd
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function sessionDirName(cwd) {
|
|
124
|
+
if (typeof cwd !== 'string' || cwd === '') throw new TypeError('cwd is required');
|
|
125
|
+
const body = cwd.replace(/[\\/]+/g, '-').split('').filter((ch) => ch !== ':').join('');
|
|
126
|
+
return '--' + body.replace(/^-+|-+$/g, '') + '--';
|
|
127
|
+
}
|