@yeaft/webchat-agent 0.1.615 → 0.1.617

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.
@@ -59,6 +59,86 @@ export function handleRestartAgent() {
59
59
  cleanupAndExit(1);
60
60
  }
61
61
 
62
+ /**
63
+ * Fetch the `engines.node` SemVer range for a specific published version of
64
+ * a package. Returns the range string (e.g. ">=22.5.0") or `null` if the
65
+ * field is absent / the lookup fails. Failure is non-fatal — callers fall
66
+ * back to running the upgrade unconditionally rather than blocking on a
67
+ * registry hiccup.
68
+ */
69
+ async function fetchRequiredNodeRange(pkgName, version) {
70
+ try {
71
+ const stdout = await new Promise((resolve, reject) => {
72
+ execFile(
73
+ npmPath,
74
+ ['view', `${pkgName}@${version}`, 'engines.node'],
75
+ { stdio: 'pipe', env: safeEnv, ...shellOpt },
76
+ (err, out) => { if (err) reject(err); else resolve(out.toString().trim()); },
77
+ );
78
+ });
79
+ return stdout || null;
80
+ } catch (e) {
81
+ console.warn(`[Agent] Could not fetch engines.node for ${pkgName}@${version}:`, e.message);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Minimal SemVer range checker — supports the subset of operators that
88
+ * appear in real-world `engines.node` fields:
89
+ * - exact: "22.5.0"
90
+ * - comparator: ">=22.5.0", ">22", "<=24", "<25.0.0"
91
+ * - whitespace AND: ">=18.0.0 <23.0.0"
92
+ * - "||" OR: ">=18 <19 || >=20"
93
+ * - "*" / "" / "x": always satisfied
94
+ *
95
+ * We deliberately avoid pulling in the `semver` npm package — the agent has
96
+ * a minimal dep set and this gate only needs to reject obviously-wrong Node
97
+ * versions. Anything we can't parse is treated as "satisfied" (fail-open)
98
+ * so a weird range never blocks a legitimate upgrade.
99
+ */
100
+ export function nodeRangeSatisfied(current, range) {
101
+ if (!range || range === '*' || range === 'x' || range === 'X') return true;
102
+ const cur = parseSemver(current);
103
+ if (!cur) return true;
104
+ const orParts = String(range).split('||').map(s => s.trim()).filter(Boolean);
105
+ if (orParts.length === 0) return true;
106
+ return orParts.some(part => part.split(/\s+/).filter(Boolean).every(cmp => compareCmp(cur, cmp)));
107
+ }
108
+
109
+ function parseSemver(v) {
110
+ if (!v) return null;
111
+ const m = String(v).replace(/^v/, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
112
+ if (!m) return null;
113
+ return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
114
+ }
115
+
116
+ function cmpTuple(a, b) {
117
+ for (let i = 0; i < 3; i++) {
118
+ if (a[i] !== b[i]) return a[i] - b[i];
119
+ }
120
+ return 0;
121
+ }
122
+
123
+ function compareCmp(cur, cmp) {
124
+ const m = cmp.match(/^(>=|<=|>|<|=|\^|~)?\s*v?(.+)$/);
125
+ if (!m) return true; // unparseable → fail-open
126
+ const op = m[1] || '=';
127
+ const target = parseSemver(m[2]);
128
+ if (!target) return true;
129
+ const d = cmpTuple(cur, target);
130
+ switch (op) {
131
+ case '>=': return d >= 0;
132
+ case '<=': return d <= 0;
133
+ case '>': return d > 0;
134
+ case '<': return d < 0;
135
+ case '=': return d === 0;
136
+ case '^': return d >= 0 && cur[0] === target[0];
137
+ case '~': return d >= 0 && cur[0] === target[0] && cur[1] === target[1];
138
+ default: return true;
139
+ }
140
+ }
141
+
62
142
  export async function handleUpgradeAgent() {
63
143
  console.log('[Agent] Upgrade requested, checking for updates...');
64
144
  try {
@@ -74,6 +154,28 @@ export async function handleUpgradeAgent() {
74
154
  sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
75
155
  return;
76
156
  }
157
+
158
+ // Node.js compatibility gate: fetch engines.node of the *target* version
159
+ // and refuse to upgrade if the running Node is too old. Without this,
160
+ // npm install would replace files and the agent would crash on next
161
+ // restart with no actionable signal.
162
+ const requiredNode = await fetchRequiredNodeRange(pkgName, latestVersion);
163
+ const currentNode = process.versions.node;
164
+ if (requiredNode && !nodeRangeSatisfied(currentNode, requiredNode)) {
165
+ const msg = `Node ${currentNode} does not satisfy required ${requiredNode} for ${pkgName}@${latestVersion}`;
166
+ console.warn(`[Agent] Upgrade aborted: ${msg}`);
167
+ sendToServer({
168
+ type: 'upgrade_agent_ack',
169
+ success: false,
170
+ reason: 'node_incompatible',
171
+ error: msg,
172
+ currentNode,
173
+ requiredNode,
174
+ version: latestVersion,
175
+ });
176
+ return;
177
+ }
178
+
77
179
  console.log(`[Agent] Upgrading from ${ctx.agentVersion} to latest (${latestVersion})...`);
78
180
 
79
181
  // 检测安装方式:npm install 的路径包含 node_modules,源码运行则不包含
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.615",
3
+ "version": "0.1.617",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -111,7 +111,7 @@ export async function runCompact({ messages, keepHot = 10, taskId = null, root,
111
111
  ? await hooks.readPriorTaskSummary() : '';
112
112
  const next = await hooks.refreshTaskSummary(coolingMessages, prior);
113
113
  if (typeof next === 'string' && next.trim()) {
114
- await writeSummary({ kind: 'task', id: taskId }, next, { root });
114
+ await writeSummary({ kind: 'feature', id: taskId }, next, { root });
115
115
  taskSummaryRefreshed = true;
116
116
  }
117
117
  }
@@ -1,25 +1,25 @@
1
1
  /**
2
- * task-message.js — R6 §Δ28 / §Δ31.6 task-scoped direct messaging.
2
+ * feature-message.js — R6 §Δ28 / §Δ31.6 feature-scoped direct messaging.
3
3
  *
4
- * Replaces the withdrawn R3 `unify_task_private_chat` event with a simple
5
- * echo-able `task_message` pair:
4
+ * Replaces the withdrawn R3 `unify_feature_private_chat` event with a simple
5
+ * echo-able `feature_message` pair:
6
6
  *
7
- * inbound (web → agent): `unify_task_message`
8
- * { type, groupId, taskId, vpId, text, mentions?, replyTo?, requestId? }
9
- * outbound (agent → web): `task_message`
10
- * { type, groupId, taskId, vpId, msgId, text, mentions, replyTo,
7
+ * inbound (web → agent): `unify_feature_message`
8
+ * { type, groupId, featureId, vpId, text, mentions?, replyTo?, requestId? }
9
+ * outbound (agent → web): `feature_message`
10
+ * { type, groupId, featureId, vpId, msgId, text, mentions, replyTo,
11
11
  * ts, requestId? }
12
12
  *
13
13
  * This module owns only the *wire adapter* — the payload is validated,
14
14
  * stamped with msgId + ts, and broadcast back so the sender's UI and any
15
- * other connected views converge on the same record. Persistence + task
15
+ * other connected views converge on the same record. Persistence + feature
16
16
  * ACL enforcement are deliberately deferred to task-334l (per PM dispatch:
17
- * "user_memory_* 实际 ingestion 归 334l"); the parallel task-private
17
+ * "user_memory_* 实际 ingestion 归 334l"); the parallel feature-private
18
18
  * storage hook follows the same phasing.
19
19
  *
20
20
  * Invariants:
21
21
  * • Never throws on the WS hot path — bad payloads reply with a
22
- * `task_message_rejected` event carrying a stable `code` string for
22
+ * `feature_message_rejected` event carrying a stable `code` string for
23
23
  * UI i18n (mirrors the vp_crud_result contract from 334-ui-g).
24
24
  * • The outbound event field order and keys are considered wire-frozen
25
25
  * per R6 §Δ31.6 table; additive fields only in future slices.
@@ -28,9 +28,9 @@
28
28
  import { nextMsgId, isValidVpId } from './groups/ids.js';
29
29
 
30
30
  /** Known `reject` codes — kept stable so 334-ui-* can key i18n on them. */
31
- export const TASK_MESSAGE_REJECT_CODES = Object.freeze({
31
+ export const FEATURE_MESSAGE_REJECT_CODES = Object.freeze({
32
32
  MISSING_GROUP_ID: 'missing_group_id',
33
- MISSING_TASK_ID: 'missing_task_id',
33
+ MISSING_FEATURE_ID: 'missing_feature_id',
34
34
  MISSING_VP_ID: 'missing_vp_id',
35
35
  INVALID_VP_ID: 'invalid_vp_id',
36
36
  EMPTY_TEXT: 'empty_text',
@@ -46,31 +46,31 @@ export const MAX_TEXT_LENGTH = 16_384;
46
46
  *
47
47
  * @param {any} msg — raw WS message from the web client
48
48
  */
49
- export function validateTaskMessage(msg) {
49
+ export function validateFeatureMessage(msg) {
50
50
  if (!msg || typeof msg !== 'object') {
51
- return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
51
+ return { ok: false, code: FEATURE_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
52
52
  }
53
- const { groupId, taskId, vpId, text } = msg;
53
+ const { groupId, featureId, vpId, text } = msg;
54
54
  if (!groupId || typeof groupId !== 'string') {
55
- return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
55
+ return { ok: false, code: FEATURE_MESSAGE_REJECT_CODES.MISSING_GROUP_ID };
56
56
  }
57
- if (!taskId || typeof taskId !== 'string') {
58
- return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_TASK_ID };
57
+ if (!featureId || typeof featureId !== 'string') {
58
+ return { ok: false, code: FEATURE_MESSAGE_REJECT_CODES.MISSING_FEATURE_ID };
59
59
  }
60
60
  if (!vpId || typeof vpId !== 'string') {
61
- return { ok: false, code: TASK_MESSAGE_REJECT_CODES.MISSING_VP_ID };
61
+ return { ok: false, code: FEATURE_MESSAGE_REJECT_CODES.MISSING_VP_ID };
62
62
  }
63
- // Allow the reserved `user` sentinel as a speaker here — tasks can have
63
+ // Allow the reserved `user` sentinel as a speaker here — features can have
64
64
  // human-user messages alongside VP messages. Any other vpId must pass
65
65
  // the full shape check (rejects `all`, `system`, pure digits, etc.).
66
66
  if (vpId !== 'user' && !isValidVpId(vpId)) {
67
- return { ok: false, code: TASK_MESSAGE_REJECT_CODES.INVALID_VP_ID };
67
+ return { ok: false, code: FEATURE_MESSAGE_REJECT_CODES.INVALID_VP_ID };
68
68
  }
69
69
  if (typeof text !== 'string' || text.length === 0) {
70
- return { ok: false, code: TASK_MESSAGE_REJECT_CODES.EMPTY_TEXT };
70
+ return { ok: false, code: FEATURE_MESSAGE_REJECT_CODES.EMPTY_TEXT };
71
71
  }
72
72
  if (text.length > MAX_TEXT_LENGTH) {
73
- return { ok: false, code: TASK_MESSAGE_REJECT_CODES.TEXT_TOO_LONG };
73
+ return { ok: false, code: FEATURE_MESSAGE_REJECT_CODES.TEXT_TOO_LONG };
74
74
  }
75
75
 
76
76
  const mentions = Array.isArray(msg.mentions)
@@ -82,25 +82,25 @@ export function validateTaskMessage(msg) {
82
82
 
83
83
  return {
84
84
  ok: true,
85
- payload: { groupId, taskId, vpId, text, mentions, replyTo },
85
+ payload: { groupId, featureId, vpId, text, mentions, replyTo },
86
86
  };
87
87
  }
88
88
 
89
89
  /**
90
- * Build the outbound `task_message` event from a validated payload.
90
+ * Build the outbound `feature_message` event from a validated payload.
91
91
  * Exposed separately so tests can snapshot the wire shape without
92
92
  * needing a live send fn.
93
93
  *
94
- * @param {{groupId:string,taskId:string,vpId:string,text:string,mentions:string[],replyTo:?string}} payload
94
+ * @param {{groupId:string,featureId:string,vpId:string,text:string,mentions:string[],replyTo:?string}} payload
95
95
  * @param {{now?:()=>number, msgId?:()=>string, requestId?:string}} [opts]
96
96
  */
97
- export function buildTaskMessageEvent(payload, opts = {}) {
97
+ export function buildFeatureMessageEvent(payload, opts = {}) {
98
98
  const now = typeof opts.now === 'function' ? opts.now : Date.now;
99
99
  const mkId = typeof opts.msgId === 'function' ? opts.msgId : nextMsgId;
100
100
  const evt = {
101
- type: 'task_message',
101
+ type: 'feature_message',
102
102
  groupId: payload.groupId,
103
- taskId: payload.taskId,
103
+ featureId: payload.featureId,
104
104
  vpId: payload.vpId,
105
105
  msgId: mkId(),
106
106
  text: payload.text,
@@ -113,15 +113,15 @@ export function buildTaskMessageEvent(payload, opts = {}) {
113
113
  }
114
114
 
115
115
  /**
116
- * Build the outbound `task_message_rejected` event.
117
- * @param {string} code — one of TASK_MESSAGE_REJECT_CODES
116
+ * Build the outbound `feature_message_rejected` event.
117
+ * @param {string} code — one of FEATURE_MESSAGE_REJECT_CODES
118
118
  * @param {any} msg — original inbound msg (for requestId echo)
119
119
  */
120
- export function buildTaskMessageRejected(code, msg) {
121
- const evt = { type: 'task_message_rejected', code };
120
+ export function buildFeatureMessageRejected(code, msg) {
121
+ const evt = { type: 'feature_message_rejected', code };
122
122
  if (msg && typeof msg.requestId === 'string') evt.requestId = msg.requestId;
123
123
  if (msg && typeof msg.groupId === 'string') evt.groupId = msg.groupId;
124
- if (msg && typeof msg.taskId === 'string') evt.taskId = msg.taskId;
124
+ if (msg && typeof msg.featureId === 'string') evt.featureId = msg.featureId;
125
125
  return evt;
126
126
  }
127
127
 
@@ -132,13 +132,13 @@ export function buildTaskMessageRejected(code, msg) {
132
132
  * @param {(event:object)=>void} sendUnifyEvent
133
133
  * @param {{now?:()=>number, msgId?:()=>string}} [opts] — test seams
134
134
  */
135
- export function handleUnifyTaskMessage(msg, sendUnifyEvent, opts = {}) {
136
- const result = validateTaskMessage(msg);
135
+ export function handleUnifyFeatureMessage(msg, sendUnifyEvent, opts = {}) {
136
+ const result = validateFeatureMessage(msg);
137
137
  if (!result.ok) {
138
- try { sendUnifyEvent(buildTaskMessageRejected(result.code, msg)); } catch { /* best-effort */ }
138
+ try { sendUnifyEvent(buildFeatureMessageRejected(result.code, msg)); } catch { /* best-effort */ }
139
139
  return;
140
140
  }
141
141
  const requestId = msg && typeof msg.requestId === 'string' ? msg.requestId : undefined;
142
- const evt = buildTaskMessageEvent(result.payload, { ...opts, requestId });
142
+ const evt = buildFeatureMessageEvent(result.payload, { ...opts, requestId });
143
143
  try { sendUnifyEvent(evt); } catch { /* never crash WS pipeline */ }
144
144
  }