@twinsearth/w2m-dsh-plugin 2.2.6 → 3.1.0
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 +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.md +1 -1
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +16 -0
- package/docs/acceptance/3.0.0/RELEASE-3000.json +52 -0
- package/docs/acceptance/3.1.0/RELEASE-3100.json +46 -0
- package/lib/client.js +603 -0
- package/lib/decision-center.js +11 -0
- package/lib/queue-journal.js +15 -0
- package/package.json +8 -4
- package/plugin.json +1 -1
- package/src/decision-center/index.mjs +2265 -0
- package/src/decision-center/kernel.mjs +165 -0
- package/src/decision-center/lib/ai-secret-store.mjs +202 -0
- package/src/decision-center/lib/decision-outbox.mjs +129 -0
- package/src/decision-center/lib/producer-contract.mjs +162 -0
- package/src/decision-center/lib/ssrf-guard.mjs +170 -0
- package/src/decision-center/mount.mjs +26 -0
- package/src/decision-center/routes.mjs +356 -0
- package/src/decision-center/vendor/cosmokit.mjs +421 -0
- package/src/decision-center/vendor/schemastery.mjs +638 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/queue-journal/index.mjs +188 -0
- package/src/queue-journal/lib/composer-journal.mjs +128 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M queue journal: `@twinsearth/w2m-dsh-plugin/queue-journal`.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `@twinsearth/dsh-fang-queue-journal` (Fang 3.1.21). Fang's package shipped only a
|
|
5
|
+
* no-op host half because its HTTP surface lived in the Fang orchestrator; W2M has no orchestrator,
|
|
6
|
+
* so the store and the four routes move into this subplugin and keep the wire shapes the ported
|
|
7
|
+
* client already speaks:
|
|
8
|
+
*
|
|
9
|
+
* GET /api/w2m/composer/journal?key=… -> { ok, entry }
|
|
10
|
+
* GET /api/w2m/composer/journal?list=1 -> { ok, entries } (recovery list, newest first)
|
|
11
|
+
* POST /api/w2m/composer/journal { key, draft, queue, path, sessionKey }
|
|
12
|
+
* GET /api/w2m/composer/journal/history?limit=&sessionKey= -> { ok, items }
|
|
13
|
+
* POST /api/w2m/composer/queue { sessionId, items:[{text}] }
|
|
14
|
+
*
|
|
15
|
+
* The journal only stores what the user has not sent. Nothing here sends a message: the queue
|
|
16
|
+
* restore route forwards rows into a live agent only when the host exposes one, and reports
|
|
17
|
+
* `session-not-live` otherwise, exactly as the Fang route did.
|
|
18
|
+
*
|
|
19
|
+
* Data lives under `$DSH_HOME/xclient/queue-journal`, beside the rest of this package's state.
|
|
20
|
+
*/
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { resolveStateDir } from '../util/cli.mjs';
|
|
24
|
+
import { ComposerJournal } from './lib/composer-journal.mjs';
|
|
25
|
+
|
|
26
|
+
export const name = 'w2m-queue-journal';
|
|
27
|
+
export const inject = [];
|
|
28
|
+
|
|
29
|
+
/** Route prefix shared with lib/client.js. */
|
|
30
|
+
export const QUEUE_JOURNAL_ROUTE_PREFIX = '/api/w2m/composer/journal';
|
|
31
|
+
export const QUEUE_RESTORE_ROUTE_PATH = '/api/w2m/composer/queue';
|
|
32
|
+
export const QUEUE_JOURNAL_SERVICE = 'w2mQueueJournal';
|
|
33
|
+
|
|
34
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
35
|
+
|
|
36
|
+
function json(status, body) {
|
|
37
|
+
return Response.json(body, { status, headers: NO_STORE });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function queryOf(request) {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(request.url, 'http://127.0.0.1').searchParams;
|
|
43
|
+
} catch {
|
|
44
|
+
return new URLSearchParams();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function bodyOf(request) {
|
|
49
|
+
try {
|
|
50
|
+
const value = await request.json();
|
|
51
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mount the four composer-journal routes on the host's Connection fetch registry.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} ctx Cordis context.
|
|
61
|
+
* @param {{journal: ComposerJournal, dataDir: string}} deps
|
|
62
|
+
* @returns {{mounted: boolean, reason: string|null}}
|
|
63
|
+
*/
|
|
64
|
+
export function registerQueueJournalRoutes(ctx, deps) {
|
|
65
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
66
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
67
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
68
|
+
const registry = webCtx?.connection?.fetch;
|
|
69
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
70
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const register = (routePath, methods, fetch) => {
|
|
74
|
+
registry.register({ path: routePath, methods, requestBody: 'buffered', fetch });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
register(QUEUE_JOURNAL_ROUTE_PREFIX, ['GET', 'POST'], async (request) => {
|
|
78
|
+
if (request.method === 'GET') {
|
|
79
|
+
const query = queryOf(request);
|
|
80
|
+
const key = String(query.get('key') ?? '');
|
|
81
|
+
if (key === '' && query.get('list') === '1') {
|
|
82
|
+
return json(200, { ok: true, entries: deps.journal.list() });
|
|
83
|
+
}
|
|
84
|
+
if (key === '') return json(400, { ok: false, error: 'journal-key-required' });
|
|
85
|
+
return json(200, { ok: true, entry: deps.journal.get(key) });
|
|
86
|
+
}
|
|
87
|
+
const body = await bodyOf(request);
|
|
88
|
+
const key = String(body?.key ?? '');
|
|
89
|
+
if (key === '') return json(400, { ok: false, error: 'journal-key-required' });
|
|
90
|
+
try {
|
|
91
|
+
return json(200, {
|
|
92
|
+
ok: true,
|
|
93
|
+
entry: deps.journal.save(key, {
|
|
94
|
+
...body,
|
|
95
|
+
sessionKey: body?.sessionKey || body?.sessionId || '',
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return json(400, { ok: false, error: String(error?.message ?? error) });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
register(`${QUEUE_JOURNAL_ROUTE_PREFIX}/history`, ['GET'], (request) => {
|
|
104
|
+
const query = queryOf(request);
|
|
105
|
+
const limit = Math.max(1, Math.min(200, Number(query.get('limit') ?? 20) || 20));
|
|
106
|
+
const sessionKey = String(query.get('sessionKey') ?? '');
|
|
107
|
+
return json(200, { ok: true, items: deps.journal.history({ limit, sessionKey }) });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
register(QUEUE_RESTORE_ROUTE_PATH, ['POST'], async (request) => {
|
|
111
|
+
const body = await bodyOf(request);
|
|
112
|
+
const sessionId = String(body?.sessionId ?? '').trim();
|
|
113
|
+
const items = Array.isArray(body?.items) ? body.items : [];
|
|
114
|
+
if (sessionId === '') return json(400, { ok: false, error: 'session-id-required' });
|
|
115
|
+
if (items.length === 0) return json(200, { ok: true, queued: 0, failed: 0 });
|
|
116
|
+
let agents;
|
|
117
|
+
try {
|
|
118
|
+
agents = ctx.get('agents');
|
|
119
|
+
} catch {
|
|
120
|
+
agents = undefined;
|
|
121
|
+
}
|
|
122
|
+
const agent = typeof agents?.get === 'function' ? agents.get(sessionId) : undefined;
|
|
123
|
+
if (agent === undefined || typeof agent.send !== 'function') {
|
|
124
|
+
return json(409, { ok: false, error: 'session-not-live' });
|
|
125
|
+
}
|
|
126
|
+
let queued = 0;
|
|
127
|
+
let failed = 0;
|
|
128
|
+
for (const item of items.slice(0, 100)) {
|
|
129
|
+
const text = String(item?.text ?? '')
|
|
130
|
+
.trim()
|
|
131
|
+
.slice(0, 4000);
|
|
132
|
+
if (text === '') {
|
|
133
|
+
failed += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
agent.send(
|
|
138
|
+
{
|
|
139
|
+
id: `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
|
|
140
|
+
role: 'user',
|
|
141
|
+
content: [{ type: 'text', text }],
|
|
142
|
+
source: { kind: 'user' },
|
|
143
|
+
},
|
|
144
|
+
'next-turn',
|
|
145
|
+
false,
|
|
146
|
+
);
|
|
147
|
+
queued += 1;
|
|
148
|
+
} catch {
|
|
149
|
+
failed += 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return json(200, { ok: true, queued, failed });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
status.mounted = true;
|
|
156
|
+
status.reason = null;
|
|
157
|
+
});
|
|
158
|
+
return status;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Apply the queue journal: one store, one service, four routes.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} ctx Cordis context.
|
|
165
|
+
* @param {{dataDir?: string}} [config]
|
|
166
|
+
*/
|
|
167
|
+
export function apply(ctx, config = {}) {
|
|
168
|
+
const dataDir =
|
|
169
|
+
typeof config?.dataDir === 'string' && config.dataDir.trim() !== ''
|
|
170
|
+
? path.resolve(config.dataDir)
|
|
171
|
+
: path.join(resolveStateDir(null, ''), 'queue-journal');
|
|
172
|
+
const journal = new ComposerJournal(dataDir);
|
|
173
|
+
const service = {
|
|
174
|
+
dataDir,
|
|
175
|
+
get: (key) => journal.get(key),
|
|
176
|
+
list: () => journal.list(),
|
|
177
|
+
save: (key, input) => journal.save(key, input),
|
|
178
|
+
history: (options) => journal.history(options),
|
|
179
|
+
remove: (key) => journal.remove(key),
|
|
180
|
+
};
|
|
181
|
+
if (typeof ctx?.effect === 'function') {
|
|
182
|
+
ctx.effect(() => ctx.provide(QUEUE_JOURNAL_SERVICE, service));
|
|
183
|
+
} else if (typeof ctx?.provide === 'function') {
|
|
184
|
+
ctx.provide(QUEUE_JOURNAL_SERVICE, service);
|
|
185
|
+
}
|
|
186
|
+
registerQueueJournalRoutes(ctx, { journal, dataDir });
|
|
187
|
+
return service;
|
|
188
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Composer journal: persist the user's unsent draft and queued-message mirror so a
|
|
3
|
+
// restart/crash can offer recovery. It never sends anything by itself.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const MAX_DRAFT = 100_000
|
|
8
|
+
const MAX_QUEUE = 100
|
|
9
|
+
const MAX_ITEM = 4_000
|
|
10
|
+
// v3.1.21:队列「必丢」根因之一是所有会话挤在同一个 key(后者覆盖前者)。
|
|
11
|
+
// 新增 append-only 历史文件:任何一次含队列的保存都会追加一条快照,
|
|
12
|
+
// 即使主 journal 被覆盖,历史里仍能找回来。
|
|
13
|
+
const MAX_HISTORY = 500
|
|
14
|
+
|
|
15
|
+
function cleanText(value, limit) {
|
|
16
|
+
return String(value ?? '').replace(/\0/g, '').slice(0, limit)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ComposerJournal {
|
|
20
|
+
constructor(dataDir) {
|
|
21
|
+
this.file = path.join(dataDir, 'composer-journal.json')
|
|
22
|
+
this.historyFile = path.join(dataDir, 'composer-journal-history.jsonl')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
load() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'))
|
|
28
|
+
return raw && typeof raw === 'object' ? raw : {}
|
|
29
|
+
} catch { return {} }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
saveFile(all) {
|
|
33
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true })
|
|
34
|
+
const tmp = this.file + '.tmp'
|
|
35
|
+
fs.writeFileSync(tmp, JSON.stringify(all, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
36
|
+
fs.renameSync(tmp, this.file)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(key) {
|
|
40
|
+
const entry = this.load()[String(key)]
|
|
41
|
+
return entry && typeof entry === 'object' ? entry : null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** All recoverable journal entries, newest first (bounded by the same 100-session cap). */
|
|
45
|
+
list() {
|
|
46
|
+
const all = this.load()
|
|
47
|
+
return Object.values(all)
|
|
48
|
+
.filter((entry) => entry && typeof entry === 'object')
|
|
49
|
+
.sort((a, b) => String(b?.updatedAt || '').localeCompare(String(a?.updatedAt || '')))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
save(key, input = {}) {
|
|
53
|
+
const id = cleanText(key, 200)
|
|
54
|
+
if (!id) throw new Error('journal-key-required')
|
|
55
|
+
const queue = Array.isArray(input.queue)
|
|
56
|
+
? input.queue.slice(-MAX_QUEUE).map((item) => ({
|
|
57
|
+
text: cleanText(item?.text, MAX_ITEM),
|
|
58
|
+
at: Number(item?.at) || Date.now(),
|
|
59
|
+
})).filter((item) => item.text.trim())
|
|
60
|
+
: []
|
|
61
|
+
const entry = {
|
|
62
|
+
key: id,
|
|
63
|
+
draft: cleanText(input.draft, MAX_DRAFT),
|
|
64
|
+
queue,
|
|
65
|
+
path: cleanText(input.path, 500),
|
|
66
|
+
sessionKey: cleanText(input.sessionKey, 200),
|
|
67
|
+
updatedAt: new Date().toISOString(),
|
|
68
|
+
}
|
|
69
|
+
const all = this.load()
|
|
70
|
+
all[id] = entry
|
|
71
|
+
// Keep the journal bounded: 100 sessions is plenty for recovery.
|
|
72
|
+
const keys = Object.keys(all)
|
|
73
|
+
if (keys.length > 100) {
|
|
74
|
+
keys.sort((a, b) => String(all[a]?.updatedAt || '').localeCompare(String(all[b]?.updatedAt || '')))
|
|
75
|
+
for (const stale of keys.slice(0, keys.length - 100)) delete all[stale]
|
|
76
|
+
}
|
|
77
|
+
this.saveFile(all)
|
|
78
|
+
this.appendHistory({ ...entry, sessionKey: cleanText(input.sessionKey, 200) || entry.key })
|
|
79
|
+
return entry
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 追加一条历史快照(只在有队列内容时写,避免草稿噪音)。 */
|
|
83
|
+
appendHistory(entry) {
|
|
84
|
+
try {
|
|
85
|
+
const queue = Array.isArray(entry?.queue) ? entry.queue : []
|
|
86
|
+
if (queue.length === 0) return
|
|
87
|
+
fs.mkdirSync(path.dirname(this.historyFile), { recursive: true })
|
|
88
|
+
fs.appendFileSync(this.historyFile, JSON.stringify({
|
|
89
|
+
key: entry.key,
|
|
90
|
+
queue,
|
|
91
|
+
draft: entry.draft || "",
|
|
92
|
+
path: entry.path || "",
|
|
93
|
+
sessionKey: entry.sessionKey || "",
|
|
94
|
+
updatedAt: entry.updatedAt,
|
|
95
|
+
}) + "\n", { encoding: "utf8", mode: 0o600 })
|
|
96
|
+
// 裁剪:超过上限时重写最后 MAX_HISTORY 行
|
|
97
|
+
const raw = fs.readFileSync(this.historyFile, "utf8").trim().split("\n").filter(Boolean)
|
|
98
|
+
if (raw.length > MAX_HISTORY) {
|
|
99
|
+
fs.writeFileSync(this.historyFile, raw.slice(-MAX_HISTORY).join("\n") + "\n", { encoding: "utf8", mode: 0o600 })
|
|
100
|
+
}
|
|
101
|
+
} catch { /* 历史写入失败不影响主 journal */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 读取历史快照(newest first),可按 sessionKey 过滤。 */
|
|
105
|
+
history({ limit = 50, sessionKey = "" } = {}) {
|
|
106
|
+
try {
|
|
107
|
+
const raw = fs.readFileSync(this.historyFile, "utf8").trim().split("\n").filter(Boolean)
|
|
108
|
+
const items = []
|
|
109
|
+
for (const line of raw) {
|
|
110
|
+
try {
|
|
111
|
+
const entry = JSON.parse(line)
|
|
112
|
+
if (sessionKey && String(entry?.sessionKey || "") !== String(sessionKey)) continue
|
|
113
|
+
items.push(entry)
|
|
114
|
+
} catch { /* 跳过坏行 */ }
|
|
115
|
+
}
|
|
116
|
+
return items.slice(-Math.max(1, Number(limit) || 50)).reverse()
|
|
117
|
+
} catch { return [] }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
remove(key) {
|
|
121
|
+
const all = this.load()
|
|
122
|
+
const id = String(key)
|
|
123
|
+
if (!all[id]) return false
|
|
124
|
+
delete all[id]
|
|
125
|
+
this.saveFile(all)
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
}
|