echoclaw-relay-agent 0.9.6 → 0.9.9
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/dist/RelayAgent.d.ts +15 -0
- package/dist/RelayAgent.js +161 -54
- package/dist/cli.js +18 -1
- package/dist/gateway/WorkspaceSync.d.ts +28 -0
- package/dist/gateway/WorkspaceSync.js +90 -0
- package/package.json +1 -1
package/dist/RelayAgent.d.ts
CHANGED
|
@@ -35,6 +35,15 @@ export declare class RelayAgent extends EventEmitter {
|
|
|
35
35
|
private readonly config;
|
|
36
36
|
private readonly subscribers;
|
|
37
37
|
private gatewayManager;
|
|
38
|
+
private _rekeyListener;
|
|
39
|
+
/** Ephemeral keypair generated on transport reconnect for proactive HELLO.
|
|
40
|
+
* Reused by the passive re-key listener to avoid glare (both sides generate
|
|
41
|
+
* different keys → ECDH mismatch). Cleared after successful re-key. */
|
|
42
|
+
private _proactiveKeyPair;
|
|
43
|
+
/** Guard against concurrent re-key attempts. If two desktop HELLOs arrive
|
|
44
|
+
* in rapid succession, the second must wait — otherwise both enter the async
|
|
45
|
+
* handler, generate different keypairs, and cause ECDH mismatch. */
|
|
46
|
+
private _rekeyInFlight;
|
|
38
47
|
constructor(config: RelayAgentConfig);
|
|
39
48
|
/** Current connection status. */
|
|
40
49
|
get status(): ClientStatus;
|
|
@@ -59,5 +68,11 @@ export declare class RelayAgent extends EventEmitter {
|
|
|
59
68
|
private onPaired;
|
|
60
69
|
private setupDataHandler;
|
|
61
70
|
private setupTransportEvents;
|
|
71
|
+
/**
|
|
72
|
+
* Install a persistent listener that responds to desktop HELLO messages
|
|
73
|
+
* with a fresh ECDH handshake. This allows re-keying at any time,
|
|
74
|
+
* not just on transport reconnect.
|
|
75
|
+
*/
|
|
76
|
+
private installRekeyListener;
|
|
62
77
|
private setStatus;
|
|
63
78
|
}
|
package/dist/RelayAgent.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* await agent.send({ type: 'greeting', text: 'Hello from OpenClaw' });
|
|
17
17
|
*/
|
|
18
18
|
import { EventEmitter } from 'node:events';
|
|
19
|
-
|
|
19
|
+
import { generateKeyPair, completeHandshake, toBase64, fromBase64, } from 'echoclaw-crypto';
|
|
20
20
|
import { RelayTransport } from './relay/RelayTransport.js';
|
|
21
21
|
import { PairingProtocol } from './relay/PairingProtocol.js';
|
|
22
22
|
import { SessionStore } from './relay/SessionStore.js';
|
|
@@ -127,6 +127,30 @@ export class RelayAgent extends EventEmitter {
|
|
|
127
127
|
writable: true,
|
|
128
128
|
value: null
|
|
129
129
|
});
|
|
130
|
+
Object.defineProperty(this, "_rekeyListener", {
|
|
131
|
+
enumerable: true,
|
|
132
|
+
configurable: true,
|
|
133
|
+
writable: true,
|
|
134
|
+
value: null
|
|
135
|
+
});
|
|
136
|
+
/** Ephemeral keypair generated on transport reconnect for proactive HELLO.
|
|
137
|
+
* Reused by the passive re-key listener to avoid glare (both sides generate
|
|
138
|
+
* different keys → ECDH mismatch). Cleared after successful re-key. */
|
|
139
|
+
Object.defineProperty(this, "_proactiveKeyPair", {
|
|
140
|
+
enumerable: true,
|
|
141
|
+
configurable: true,
|
|
142
|
+
writable: true,
|
|
143
|
+
value: null
|
|
144
|
+
});
|
|
145
|
+
/** Guard against concurrent re-key attempts. If two desktop HELLOs arrive
|
|
146
|
+
* in rapid succession, the second must wait — otherwise both enter the async
|
|
147
|
+
* handler, generate different keypairs, and cause ECDH mismatch. */
|
|
148
|
+
Object.defineProperty(this, "_rekeyInFlight", {
|
|
149
|
+
enumerable: true,
|
|
150
|
+
configurable: true,
|
|
151
|
+
writable: true,
|
|
152
|
+
value: false
|
|
153
|
+
});
|
|
130
154
|
this.config = config;
|
|
131
155
|
this.sessionStore = new SessionStore(config.sessionPath);
|
|
132
156
|
// V2: Create gateway manager if enabled
|
|
@@ -225,6 +249,9 @@ export class RelayAgent extends EventEmitter {
|
|
|
225
249
|
this.sessionKey = null;
|
|
226
250
|
this.frameCrypto = null;
|
|
227
251
|
this._dataHandler = null;
|
|
252
|
+
this._rekeyListener = null;
|
|
253
|
+
this._proactiveKeyPair = null;
|
|
254
|
+
this._rekeyInFlight = false;
|
|
228
255
|
this.setStatus('disconnected');
|
|
229
256
|
}
|
|
230
257
|
// ── Private ────────────────────────────────────────────────
|
|
@@ -267,7 +294,10 @@ export class RelayAgent extends EventEmitter {
|
|
|
267
294
|
this.pairing = new PairingProtocol(this.transport);
|
|
268
295
|
this.transport.connect();
|
|
269
296
|
try {
|
|
270
|
-
|
|
297
|
+
// Pass saved pairing code so HKDF info matches the desktop side.
|
|
298
|
+
// Without this, agent derives with undefined while desktop derives
|
|
299
|
+
// with the saved code → AES-GCM key mismatch → decrypt failure.
|
|
300
|
+
const result = await this.pairing.pairAsAgent(session.pairingCode || undefined);
|
|
271
301
|
await this.onPaired(result);
|
|
272
302
|
}
|
|
273
303
|
catch (err) {
|
|
@@ -308,6 +338,10 @@ export class RelayAgent extends EventEmitter {
|
|
|
308
338
|
lastConnected: new Date().toISOString(),
|
|
309
339
|
});
|
|
310
340
|
this.setupDataHandler();
|
|
341
|
+
// Re-install re-key listener so future desktop HELLOs are handled.
|
|
342
|
+
// Without this, after a re-key, the listener references the old transport
|
|
343
|
+
// message handler and subsequent re-keys silently fail (Reviewer #3 finding).
|
|
344
|
+
this.installRekeyListener();
|
|
311
345
|
this.setStatus('connected');
|
|
312
346
|
this.emit('paired', {
|
|
313
347
|
pairingCode: result.pairingCode,
|
|
@@ -387,63 +421,136 @@ export class RelayAgent extends EventEmitter {
|
|
|
387
421
|
this.transport.on('error', (err) => {
|
|
388
422
|
this.emit('error', err);
|
|
389
423
|
});
|
|
390
|
-
this.transport.on('open', () => {
|
|
391
|
-
if (this._paired
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
//
|
|
424
|
-
if (this.
|
|
425
|
-
this._rekeyRetryCount = 0; // Reset for next cycle
|
|
426
|
-
this.emit('error', new Error(`Re-key failed 5 times, forcing fresh reconnect`));
|
|
427
|
-
if (this.transport) {
|
|
428
|
-
this.transport.disconnect();
|
|
429
|
-
this.transport.restart();
|
|
430
|
-
}
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
// Re-key failed — keep old crypto intact, mark as connected
|
|
434
|
-
// (data handler still works with old keys if desktop didn't re-key either)
|
|
435
|
-
if (this._paired && this.sessionKey) {
|
|
436
|
-
this.setStatus('connected');
|
|
437
|
-
}
|
|
438
|
-
// If WS is still OPEN, force reconnect to trigger fresh on('open')
|
|
439
|
-
if (this.transport?.isConnected) {
|
|
424
|
+
this.transport.on('open', async () => {
|
|
425
|
+
if (!this.transport || !this._paired || !this.sessionKey)
|
|
426
|
+
return;
|
|
427
|
+
// Transport reconnected after a disconnect.
|
|
428
|
+
// Don't emit 'connected' yet — the old sessionKey is stale because
|
|
429
|
+
// desktop will re-key. Emitting 'connected' would let consumers call
|
|
430
|
+
// send() with the old key, causing decrypt failures on the other side.
|
|
431
|
+
// Status stays 'reconnecting' until onPaired() completes the re-key.
|
|
432
|
+
//
|
|
433
|
+
// Send a proactive HELLO so the desktop knows we're back and can
|
|
434
|
+
// start re-keying immediately — prevents deadlock where both sides
|
|
435
|
+
// wait for the other to initiate.
|
|
436
|
+
//
|
|
437
|
+
// The keypair is stored in _proactiveKeyPair so the passive re-key
|
|
438
|
+
// listener can reuse the SAME keypair when desktop responds with its
|
|
439
|
+
// own HELLO. ECDH is symmetric: ECDH(a, B) == ECDH(b, A), so both
|
|
440
|
+
// sides derive the same session key regardless of message ordering.
|
|
441
|
+
try {
|
|
442
|
+
this._proactiveKeyPair = await generateKeyPair();
|
|
443
|
+
this.transport.send(this.transport.buildMessage('HELLO', this.sessionId, 'agent', {
|
|
444
|
+
pubkey: toBase64(this._proactiveKeyPair.publicKey),
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
// Non-fatal — passive listener still works as fallback
|
|
449
|
+
this._proactiveKeyPair = null;
|
|
450
|
+
this.emit('error', new Error(`Proactive HELLO failed: ${err instanceof Error ? err.message : err}`));
|
|
451
|
+
}
|
|
452
|
+
// Timeout: if desktop doesn't respond within 30s, clear stale keypair
|
|
453
|
+
// and force reconnect so we get a fresh attempt.
|
|
454
|
+
setTimeout(() => {
|
|
455
|
+
if (this._proactiveKeyPair) {
|
|
456
|
+
this._proactiveKeyPair = null;
|
|
457
|
+
// If still not re-keyed, force reconnect
|
|
458
|
+
if (this._status === 'reconnecting' && this.transport) {
|
|
440
459
|
this.transport.disconnect();
|
|
441
|
-
this.transport.restart();
|
|
442
460
|
}
|
|
443
|
-
|
|
461
|
+
}
|
|
462
|
+
}, 30000);
|
|
463
|
+
// V2: Reattach gateway callbacks after reconnect
|
|
464
|
+
if (this.gatewayManager) {
|
|
465
|
+
this.gatewayManager.onReconnected(async (r) => {
|
|
466
|
+
try {
|
|
467
|
+
await this.send(r);
|
|
468
|
+
}
|
|
469
|
+
catch { /* send may fail */ }
|
|
444
470
|
});
|
|
445
471
|
}
|
|
446
472
|
});
|
|
473
|
+
// Passive re-key listener: always respond to desktop HELLO messages,
|
|
474
|
+
// even when already paired. This fixes the critical bug where:
|
|
475
|
+
// - CLI→service transition: desktop reconnects, sends HELLO, but
|
|
476
|
+
// agent has no active PairingProtocol to respond → timeout → circuit breaker.
|
|
477
|
+
// - Desktop restart: resumeSession() sends HELLO, agent doesn't respond.
|
|
478
|
+
//
|
|
479
|
+
// The listener is persistent — it stays active for the lifetime of the transport.
|
|
480
|
+
// It only triggers when _paired=true AND no _activePairing is in-flight.
|
|
481
|
+
this.installRekeyListener();
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Install a persistent listener that responds to desktop HELLO messages
|
|
485
|
+
* with a fresh ECDH handshake. This allows re-keying at any time,
|
|
486
|
+
* not just on transport reconnect.
|
|
487
|
+
*/
|
|
488
|
+
installRekeyListener() {
|
|
489
|
+
if (!this.transport)
|
|
490
|
+
return;
|
|
491
|
+
// Remove previous listener to prevent accumulation
|
|
492
|
+
if (this._rekeyListener) {
|
|
493
|
+
this.transport.removeListener('message', this._rekeyListener);
|
|
494
|
+
}
|
|
495
|
+
this._rekeyListener = async (msg) => {
|
|
496
|
+
// Only respond to desktop HELLO with pubkey when:
|
|
497
|
+
// 1. We're already paired (initial pairing is handled by PairingProtocol)
|
|
498
|
+
// 2. No active PairingProtocol in-flight (avoid competing listeners)
|
|
499
|
+
// 3. Transport is still alive (null guard per Reviewer #3)
|
|
500
|
+
if (msg.type !== 'HELLO' ||
|
|
501
|
+
msg.sender_role !== 'desktop' ||
|
|
502
|
+
!msg.pubkey ||
|
|
503
|
+
!this._paired ||
|
|
504
|
+
this._activePairing ||
|
|
505
|
+
!this.transport ||
|
|
506
|
+
this._rekeyInFlight // Prevent concurrent re-key (Reviewer #1 P1)
|
|
507
|
+
) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this._rekeyInFlight = true;
|
|
511
|
+
try {
|
|
512
|
+
// Reuse proactive keypair if available (sent during on('open')),
|
|
513
|
+
// otherwise generate a fresh one. This avoids "glare" — if both
|
|
514
|
+
// sides independently generate keypairs, the ECDH results won't
|
|
515
|
+
// match because each side would use a different agent pubkey.
|
|
516
|
+
let keyPair;
|
|
517
|
+
let alreadySentHello = false;
|
|
518
|
+
if (this._proactiveKeyPair) {
|
|
519
|
+
keyPair = this._proactiveKeyPair;
|
|
520
|
+
this._proactiveKeyPair = null; // Consume — single use
|
|
521
|
+
alreadySentHello = true; // HELLO was already sent in on('open')
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
keyPair = await generateKeyPair();
|
|
525
|
+
}
|
|
526
|
+
// Only send HELLO if we haven't already (proactive path)
|
|
527
|
+
if (!alreadySentHello && this.transport) {
|
|
528
|
+
this.transport.send(this.transport.buildMessage('HELLO', this.sessionId, 'agent', {
|
|
529
|
+
pubkey: toBase64(keyPair.publicKey),
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
// Complete ECDH with desktop's public key
|
|
533
|
+
const theirPub = fromBase64(msg.pubkey);
|
|
534
|
+
const sessionKey = await completeHandshake(keyPair.privateKey, theirPub, this.pairingCode || undefined);
|
|
535
|
+
// Update crypto state
|
|
536
|
+
this._rekeyRetryCount = 0;
|
|
537
|
+
this._proactiveKeyPair = null; // Ensure cleanup
|
|
538
|
+
await this.onPaired({
|
|
539
|
+
sessionKey,
|
|
540
|
+
sessionId: msg.session_id || this.sessionId,
|
|
541
|
+
pairingCode: this.pairingCode,
|
|
542
|
+
});
|
|
543
|
+
this.emit('rekeyed', { sessionId: this.sessionId });
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
this._proactiveKeyPair = null; // Cleanup on error
|
|
547
|
+
this.emit('error', new Error(`Passive re-key failed: ${err instanceof Error ? err.message : err}`));
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
this._rekeyInFlight = false;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
this.transport.on('message', this._rekeyListener);
|
|
447
554
|
}
|
|
448
555
|
setStatus(status) {
|
|
449
556
|
if (this._status !== status) {
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { RelayAgent } from './RelayAgent.js';
|
|
13
13
|
import { getServiceManager } from './service/platform.js';
|
|
14
14
|
import { ChatHandler } from './chat/ChatHandler.js';
|
|
15
|
+
import { handleMemorySync } from './gateway/WorkspaceSync.js';
|
|
15
16
|
const DEFAULT_RELAY = 'wss://relay.echoclaw.me';
|
|
16
17
|
// ── ASCII Banners ───────────────────────────────────────────
|
|
17
18
|
const RESET = '\x1b[0m';
|
|
@@ -366,7 +367,23 @@ async function runDaemon(relay, code, gateway, bridgePort) {
|
|
|
366
367
|
const payloadType = payload?.type;
|
|
367
368
|
const isChatMsg = payloadType === 'chat' || payloadType === 'system_prompt' ||
|
|
368
369
|
payloadType === 'chat_abort' || payloadType === 'chat_history';
|
|
369
|
-
if (
|
|
370
|
+
if (payloadType === 'memory_sync') {
|
|
371
|
+
// Desktop sends guide content for OpenClaw workspace
|
|
372
|
+
console.log(` [memory_sync] version=${payload.version}`);
|
|
373
|
+
try {
|
|
374
|
+
const ack = await handleMemorySync(payload);
|
|
375
|
+
console.log(` [memory_sync] ${ack.status} (v${ack.version})`);
|
|
376
|
+
await agent.send(ack);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
console.error(` [memory_sync] error: ${err.message}`);
|
|
380
|
+
try {
|
|
381
|
+
await agent.send({ type: 'memory_sync_ack', version: payload.version, status: 'error', error: err.message });
|
|
382
|
+
}
|
|
383
|
+
catch { /* send may fail */ }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else if (isChatMsg) {
|
|
370
387
|
console.log(` [message] type=${payloadType}`);
|
|
371
388
|
// Route chat-related messages to ChatHandler
|
|
372
389
|
// sendBack callback is for immediate error responses; async events use persistent callback
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceSync — Write memory/guide files to OpenClaw workspace.
|
|
3
|
+
*
|
|
4
|
+
* Handles `memory_sync` messages from the desktop client:
|
|
5
|
+
* 1. Check version — skip if already written
|
|
6
|
+
* 2. Write MEMORY_SECTION.md, ECHOCLAW_DEV_GUIDE.md, CLAWAPP_SPEC.md
|
|
7
|
+
* 3. Update/append the EchoClaw section in MEMORY.md
|
|
8
|
+
* 4. Write version marker
|
|
9
|
+
* 5. Reply with ack
|
|
10
|
+
*/
|
|
11
|
+
export interface MemorySyncPayload {
|
|
12
|
+
type: 'memory_sync';
|
|
13
|
+
version: string;
|
|
14
|
+
memory_section: string;
|
|
15
|
+
dev_guide: string;
|
|
16
|
+
spec: string;
|
|
17
|
+
}
|
|
18
|
+
export interface MemorySyncAck {
|
|
19
|
+
type: 'memory_sync_ack';
|
|
20
|
+
version: string;
|
|
21
|
+
status: 'written' | 'skipped' | 'error';
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Handle a memory_sync message: write guide files to OpenClaw workspace.
|
|
26
|
+
* Returns the ack payload to send back to the desktop.
|
|
27
|
+
*/
|
|
28
|
+
export declare function handleMemorySync(payload: MemorySyncPayload): Promise<MemorySyncAck>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceSync — Write memory/guide files to OpenClaw workspace.
|
|
3
|
+
*
|
|
4
|
+
* Handles `memory_sync` messages from the desktop client:
|
|
5
|
+
* 1. Check version — skip if already written
|
|
6
|
+
* 2. Write MEMORY_SECTION.md, ECHOCLAW_DEV_GUIDE.md, CLAWAPP_SPEC.md
|
|
7
|
+
* 3. Update/append the EchoClaw section in MEMORY.md
|
|
8
|
+
* 4. Write version marker
|
|
9
|
+
* 5. Reply with ack
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
const WORKSPACE_DIR = join(homedir(), '.openclaw', 'workspace');
|
|
15
|
+
const VERSION_FILE = '.echoclaw-guide-version';
|
|
16
|
+
// Exact heading used in MEMORY.md for the EchoClaw section.
|
|
17
|
+
const SECTION_HEADING = '## EchoClaw 应用平台';
|
|
18
|
+
// Regex built from SECTION_HEADING to prevent constant/regex drift.
|
|
19
|
+
// Handles both LF and CRLF line endings to avoid data loss on Windows.
|
|
20
|
+
const SECTION_RE = new RegExp(SECTION_HEADING.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
21
|
+
'[\\s\\S]*?(?=\\r?\\n## |$)');
|
|
22
|
+
// Mutex: prevent concurrent memory_sync from corrupting MEMORY.md
|
|
23
|
+
let _syncInFlight = false;
|
|
24
|
+
/**
|
|
25
|
+
* Handle a memory_sync message: write guide files to OpenClaw workspace.
|
|
26
|
+
* Returns the ack payload to send back to the desktop.
|
|
27
|
+
*/
|
|
28
|
+
export async function handleMemorySync(payload) {
|
|
29
|
+
// Input validation (P1 #3: prevent undefined fields from bricking state)
|
|
30
|
+
if (!payload.version || typeof payload.version !== 'string') {
|
|
31
|
+
return { type: 'memory_sync_ack', version: payload.version ?? '', status: 'error', error: 'missing version' };
|
|
32
|
+
}
|
|
33
|
+
if (typeof payload.memory_section !== 'string' ||
|
|
34
|
+
typeof payload.dev_guide !== 'string' ||
|
|
35
|
+
typeof payload.spec !== 'string') {
|
|
36
|
+
return { type: 'memory_sync_ack', version: payload.version, status: 'error', error: 'missing content fields' };
|
|
37
|
+
}
|
|
38
|
+
// Concurrency guard (P1 #6: prevent concurrent writes corrupting MEMORY.md)
|
|
39
|
+
if (_syncInFlight) {
|
|
40
|
+
return { type: 'memory_sync_ack', version: payload.version, status: 'skipped' };
|
|
41
|
+
}
|
|
42
|
+
_syncInFlight = true;
|
|
43
|
+
try {
|
|
44
|
+
return await doSync(payload);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
_syncInFlight = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function doSync(payload) {
|
|
51
|
+
const workspacePath = WORKSPACE_DIR;
|
|
52
|
+
try {
|
|
53
|
+
// Ensure workspace directory exists
|
|
54
|
+
await mkdir(workspacePath, { recursive: true });
|
|
55
|
+
const versionFilePath = join(workspacePath, VERSION_FILE);
|
|
56
|
+
// 1. Check existing version — skip if same
|
|
57
|
+
const existingVersion = await readFile(versionFilePath, 'utf-8').catch(() => '');
|
|
58
|
+
if (existingVersion.trim() === payload.version.trim()) {
|
|
59
|
+
return { type: 'memory_sync_ack', version: payload.version, status: 'skipped' };
|
|
60
|
+
}
|
|
61
|
+
// 2. Write individual guide files
|
|
62
|
+
await writeFile(join(workspacePath, 'MEMORY_SECTION.md'), payload.memory_section, 'utf-8');
|
|
63
|
+
await writeFile(join(workspacePath, 'ECHOCLAW_DEV_GUIDE.md'), payload.dev_guide, 'utf-8');
|
|
64
|
+
await writeFile(join(workspacePath, 'CLAWAPP_SPEC.md'), payload.spec, 'utf-8');
|
|
65
|
+
// 3. Update MEMORY.md — replace existing EchoClaw section or append
|
|
66
|
+
const memoryPath = join(workspacePath, 'MEMORY.md');
|
|
67
|
+
const memory = await readFile(memoryPath, 'utf-8').catch(() => '');
|
|
68
|
+
if (memory.includes(SECTION_HEADING)) {
|
|
69
|
+
// Replace existing section using SECTION_RE (handles CRLF).
|
|
70
|
+
// Use function form to prevent $& / $' / $` in content being interpreted.
|
|
71
|
+
const updated = memory.replace(SECTION_RE, () => payload.memory_section);
|
|
72
|
+
await writeFile(memoryPath, updated, 'utf-8');
|
|
73
|
+
}
|
|
74
|
+
else if (memory.length > 0) {
|
|
75
|
+
// Append to existing MEMORY.md with blank line separator
|
|
76
|
+
await appendFile(memoryPath, '\n\n' + payload.memory_section, 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
// Create new MEMORY.md
|
|
80
|
+
await writeFile(memoryPath, payload.memory_section, 'utf-8');
|
|
81
|
+
}
|
|
82
|
+
// 4. Write version marker (last — so partial failures re-trigger on next attempt)
|
|
83
|
+
await writeFile(versionFilePath, payload.version + '\n', 'utf-8');
|
|
84
|
+
return { type: 'memory_sync_ack', version: payload.version, status: 'written' };
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
88
|
+
return { type: 'memory_sync_ack', version: payload.version, status: 'error', error: msg };
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
CHANGED