@keyoku/openclaw 1.2.12 → 1.2.14

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.
Files changed (43) hide show
  1. package/dist/capture.d.ts +0 -10
  2. package/dist/capture.d.ts.map +1 -1
  3. package/dist/capture.js +0 -69
  4. package/dist/capture.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +1 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/context.d.ts +2 -2
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +134 -87
  11. package/dist/context.js.map +1 -1
  12. package/dist/heartbeat-migration.d.ts +47 -0
  13. package/dist/heartbeat-migration.d.ts.map +1 -0
  14. package/dist/heartbeat-migration.js +178 -0
  15. package/dist/heartbeat-migration.js.map +1 -0
  16. package/dist/heartbeat-setup.d.ts.map +1 -1
  17. package/dist/heartbeat-setup.js +22 -16
  18. package/dist/heartbeat-setup.js.map +1 -1
  19. package/dist/hooks.d.ts.map +1 -1
  20. package/dist/hooks.js +88 -6
  21. package/dist/hooks.js.map +1 -1
  22. package/dist/incremental-capture.d.ts.map +1 -1
  23. package/dist/incremental-capture.js +2 -1
  24. package/dist/incremental-capture.js.map +1 -1
  25. package/dist/init.d.ts.map +1 -1
  26. package/dist/init.js +374 -110
  27. package/dist/init.js.map +1 -1
  28. package/dist/migrate-vector-store.d.ts.map +1 -1
  29. package/dist/migrate-vector-store.js +1 -4
  30. package/dist/migrate-vector-store.js.map +1 -1
  31. package/dist/migration.d.ts +12 -0
  32. package/dist/migration.d.ts.map +1 -1
  33. package/dist/migration.js +1 -1
  34. package/dist/migration.js.map +1 -1
  35. package/dist/service.d.ts +13 -0
  36. package/dist/service.d.ts.map +1 -1
  37. package/dist/service.js +4 -4
  38. package/dist/service.js.map +1 -1
  39. package/dist/tools.d.ts.map +1 -1
  40. package/dist/tools.js +37 -12
  41. package/dist/tools.js.map +1 -1
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +3 -3
package/dist/init.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * 9. Offers migration of existing OpenClaw memories
15
15
  * 10. Health check to verify everything works
16
16
  */
17
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, createWriteStream, cpSync } from 'node:fs';
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, createWriteStream, cpSync, } from 'node:fs';
18
18
  import { join, dirname } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { createInterface } from 'node:readline';
@@ -22,6 +22,8 @@ import { pipeline } from 'node:stream/promises';
22
22
  import { KeyokuClient } from '@keyoku/memory';
23
23
  import { importMemoryFiles } from './migration.js';
24
24
  import { migrateAllVectorStores, discoverVectorDbs } from './migrate-vector-store.js';
25
+ import { extractHeartbeatRules, migrateHeartbeatRules, } from './heartbeat-migration.js';
26
+ import { findKeyokuBinary, loadKeyokuEnv, waitForHealthy } from './service.js';
25
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
28
  const HOME = process.env.HOME ?? '';
27
29
  const OPENCLAW_CONFIG_PATH = join(HOME, '.openclaw', 'openclaw.json');
@@ -48,7 +50,7 @@ const c = {
48
50
  };
49
51
  // ── Output Helpers ───────────────────────────────────────────────────────
50
52
  let currentStep = 0;
51
- const totalSteps = 10;
53
+ const totalSteps = 11;
52
54
  function stepHeader(label) {
53
55
  currentStep++;
54
56
  console.log('');
@@ -187,7 +189,7 @@ async function downloadBinary() {
187
189
  fail(`Could not fetch latest release: ${releaseRes.status} ${releaseRes.statusText}`);
188
190
  return false;
189
191
  }
190
- const release = await releaseRes.json();
192
+ const release = (await releaseRes.json());
191
193
  const asset = release.assets.find((a) => a.name === assetName);
192
194
  if (!asset) {
193
195
  fail(`No binary found for ${os}/${arch} in release ${release.tag_name}`);
@@ -357,14 +359,15 @@ function installSkill() {
357
359
  * Install HEARTBEAT.md to the OpenClaw workspace directory.
358
360
  * Must be done at init time (not at plugin register time) because OpenClaw's
359
361
  * heartbeat system overwrites the file with a default placeholder after plugins register.
362
+ *
363
+ * If an existing HEARTBEAT.md has user content, preserves it and appends keyoku section.
364
+ * Returns extracted heartbeat rules for migration into Keyoku.
360
365
  */
361
366
  function installHeartbeatMd() {
362
367
  const workspaceDir = join(HOME, '.openclaw', 'workspace');
363
368
  mkdirSync(workspaceDir, { recursive: true });
364
369
  const heartbeatPath = join(workspaceDir, 'HEARTBEAT.md');
365
- const HEARTBEAT_CONTENT = `# Heartbeat Check
366
-
367
- <!-- keyoku-heartbeat-start -->
370
+ const KEYOKU_SECTION = `<!-- keyoku-heartbeat-start -->
368
371
  ## Keyoku Memory Heartbeat
369
372
 
370
373
  You have been checked in on. Your memory system has reviewed your recent activity and surfaced anything that needs your attention. The signals are injected into your context automatically — look for the <heartbeat-signals> block.
@@ -380,85 +383,50 @@ IMPORTANT: If the signals contain \`should_act: true\` or a "Tell the User" sect
380
383
  5. ONLY reply HEARTBEAT_OK if \`should_act\` is false AND there is truly nothing in the signals worth mentioning.
381
384
 
382
385
  Do not repeat old tasks from prior conversations. Only act on what the signals say right now.
383
- <!-- keyoku-heartbeat-end -->
384
- `;
385
- // If file exists and already has keyoku section, skip
386
+ <!-- keyoku-heartbeat-end -->`;
387
+ let extractedRules = [];
386
388
  if (existsSync(heartbeatPath)) {
387
389
  const existing = readFileSync(heartbeatPath, 'utf-8');
390
+ // Extract user rules before modifying the file
391
+ extractedRules = extractHeartbeatRules(existing);
392
+ if (extractedRules.length > 0) {
393
+ info(`Found ${extractedRules.length} heartbeat rule(s) to migrate`);
394
+ }
388
395
  if (existing.includes('keyoku-heartbeat-start')) {
389
396
  success('HEARTBEAT.md already configured');
390
- return;
397
+ return extractedRules;
391
398
  }
399
+ // Preserve existing user content, append keyoku section
400
+ const content = existing.trimEnd() + `\n\n---\n\n${KEYOKU_SECTION}\n`;
401
+ writeFileSync(heartbeatPath, content, 'utf-8');
402
+ success(`HEARTBEAT.md updated (preserved existing content) → ${c.dim}~/.openclaw/workspace/${c.reset}`);
403
+ return extractedRules;
392
404
  }
393
- writeFileSync(heartbeatPath, HEARTBEAT_CONTENT, 'utf-8');
405
+ // No existing file — write full template
406
+ writeFileSync(heartbeatPath, `# Heartbeat Check\n\n${KEYOKU_SECTION}\n`, 'utf-8');
394
407
  success(`HEARTBEAT.md installed → ${c.dim}~/.openclaw/workspace/${c.reset}`);
408
+ return extractedRules;
395
409
  }
396
410
  // ── LLM Provider Setup ──────────────────────────────────────────────────
397
411
  /**
398
412
  * Set up LLM provider and API keys.
399
- * Embeddings auto-match the extraction provider (no separate key needed for Gemini).
413
+ *
414
+ * Flow:
415
+ * 5a. Embedding provider (Gemini or OpenAI only — Anthropic has no embedding models)
416
+ * 5b. Extraction provider (Gemini, OpenAI, or Anthropic)
417
+ * 5c. Extraction model (depends on provider, shows benchmark notes)
418
+ * 5d. API key(s) for each unique provider selected
400
419
  */
401
420
  async function setupLlmProvider() {
402
421
  // Check existing env vars
403
422
  const hasOpenAI = !!process.env.OPENAI_API_KEY;
404
423
  const hasGemini = !!process.env.GEMINI_API_KEY;
405
- // Extraction provider
406
- const currentProvider = process.env.KEYOKU_EXTRACTION_PROVIDER;
407
- if (currentProvider) {
408
- success(`Extraction: ${c.bold}${currentProvider}${c.reset} (${process.env.KEYOKU_EXTRACTION_MODEL || 'default model'})`);
409
- }
410
- else {
411
- // Auto-detect best available provider
412
- if (hasGemini) {
413
- appendToEnvFile('KEYOKU_EXTRACTION_PROVIDER', 'gemini');
414
- appendToEnvFile('KEYOKU_EXTRACTION_MODEL', 'gemini-2.5-flash');
415
- appendToEnvFile('KEYOKU_EMBEDDING_PROVIDER', 'gemini');
416
- appendToEnvFile('KEYOKU_EMBEDDING_MODEL', 'gemini-embedding-001');
417
- success(`Auto-configured: ${c.bold}Gemini${c.reset} for extraction + embeddings`);
418
- }
419
- else if (hasOpenAI) {
420
- appendToEnvFile('KEYOKU_EXTRACTION_PROVIDER', 'openai');
421
- appendToEnvFile('KEYOKU_EXTRACTION_MODEL', 'gpt-5-mini');
422
- appendToEnvFile('KEYOKU_EMBEDDING_PROVIDER', 'openai');
423
- appendToEnvFile('KEYOKU_EMBEDDING_MODEL', 'text-embedding-3-small');
424
- success(`Auto-configured: ${c.bold}OpenAI${c.reset} for extraction + embeddings`);
425
- }
426
- else {
427
- // No API key detected — prompt for provider
428
- const provider = await choose('Which LLM provider?', [
429
- { label: 'OpenAI', value: 'openai', desc: 'extraction + embeddings' },
430
- { label: 'Gemini', value: 'gemini', desc: 'extraction + embeddings' },
431
- ]);
432
- if (provider === 'gemini') {
433
- const key = await prompt('Gemini API key:');
434
- if (key) {
435
- appendToEnvFile('GEMINI_API_KEY', key);
436
- appendToEnvFile('KEYOKU_EXTRACTION_PROVIDER', 'gemini');
437
- appendToEnvFile('KEYOKU_EXTRACTION_MODEL', 'gemini-2.5-flash');
438
- appendToEnvFile('KEYOKU_EMBEDDING_PROVIDER', 'gemini');
439
- appendToEnvFile('KEYOKU_EMBEDDING_MODEL', 'gemini-embedding-001');
440
- success('Gemini configured');
441
- }
442
- else {
443
- warn('No key provided — set GEMINI_API_KEY manually');
444
- }
445
- }
446
- else {
447
- // Default: OpenAI
448
- const key = await prompt('OpenAI API key (sk-...):');
449
- if (key && key.startsWith('sk-')) {
450
- appendToEnvFile('OPENAI_API_KEY', key);
451
- appendToEnvFile('KEYOKU_EXTRACTION_PROVIDER', 'openai');
452
- appendToEnvFile('KEYOKU_EXTRACTION_MODEL', 'gpt-5-mini');
453
- appendToEnvFile('KEYOKU_EMBEDDING_PROVIDER', 'openai');
454
- appendToEnvFile('KEYOKU_EMBEDDING_MODEL', 'text-embedding-3-small');
455
- success('OpenAI configured');
456
- }
457
- else {
458
- warn('Invalid key — set OPENAI_API_KEY manually');
459
- }
460
- }
461
- }
424
+ const hasAnthropic = !!process.env.ANTHROPIC_API_KEY;
425
+ // Show current config if already set (but still allow reconfiguration)
426
+ const currentExtraction = process.env.KEYOKU_EXTRACTION_PROVIDER;
427
+ const currentEmbedding = process.env.KEYOKU_EMBEDDING_PROVIDER;
428
+ if (currentExtraction && currentEmbedding) {
429
+ info(`Current: ${c.dim}${currentExtraction}/${process.env.KEYOKU_EXTRACTION_MODEL || 'default'} + ${currentEmbedding}/${process.env.KEYOKU_EMBEDDING_MODEL || 'default'}${c.reset}`);
462
430
  }
463
431
  // Show detected API keys
464
432
  const detected = [];
@@ -466,9 +434,108 @@ async function setupLlmProvider() {
466
434
  detected.push('OpenAI');
467
435
  if (hasGemini)
468
436
  detected.push('Gemini');
437
+ if (hasAnthropic)
438
+ detected.push('Anthropic');
469
439
  if (detected.length > 0) {
470
440
  success(`API keys detected: ${c.bold}${detected.join(', ')}${c.reset}`);
471
441
  }
442
+ // ── 5a: Embedding provider ──────────────────────────────────────────
443
+ console.log('');
444
+ log('Embeddings convert text into vectors for semantic search.');
445
+ log(`${c.yellow}Anthropic does not offer embedding models.${c.reset}`);
446
+ const embeddingProvider = await choose('Embedding provider?', [
447
+ { label: 'Gemini', value: 'gemini', desc: 'gemini-embedding-001' },
448
+ { label: 'OpenAI', value: 'openai', desc: 'text-embedding-3-small' },
449
+ ]);
450
+ if (embeddingProvider === 'gemini') {
451
+ appendToEnvFile('KEYOKU_EMBEDDING_PROVIDER', 'gemini'); // engine uses "gemini"
452
+ appendToEnvFile('KEYOKU_EMBEDDING_MODEL', 'gemini-embedding-001');
453
+ }
454
+ else {
455
+ appendToEnvFile('KEYOKU_EMBEDDING_PROVIDER', 'openai');
456
+ appendToEnvFile('KEYOKU_EMBEDDING_MODEL', 'text-embedding-3-small');
457
+ }
458
+ success(`Embedding → ${c.bold}${embeddingProvider}${c.reset}`);
459
+ // ── 5b: Extraction provider ─────────────────────────────────────────
460
+ const extractionProvider = await choose('Extraction provider?', [
461
+ { label: 'Gemini (recommended)', value: 'gemini', desc: 'Best price-to-quality ratio' },
462
+ { label: 'OpenAI', value: 'openai', desc: 'Reliable, widely supported' },
463
+ {
464
+ label: 'Anthropic',
465
+ value: 'anthropic',
466
+ desc: 'Highest quality, no embeddings (uses your embedding provider)',
467
+ },
468
+ ]);
469
+ appendToEnvFile('KEYOKU_EXTRACTION_PROVIDER', extractionProvider);
470
+ // ── 5c: Extraction model ────────────────────────────────────────────
471
+ let extractionModel;
472
+ if (extractionProvider === 'gemini') {
473
+ extractionModel = await choose('Extraction model?', [
474
+ {
475
+ label: 'gemini-3.1-flash-lite-preview (recommended)',
476
+ value: 'gemini-3.1-flash-lite-preview',
477
+ desc: 'Cheapest and fastest, near-perfect quality',
478
+ },
479
+ {
480
+ label: 'gemini-2.5-flash',
481
+ value: 'gemini-2.5-flash',
482
+ desc: 'Thinking model, highest accuracy, slower',
483
+ },
484
+ ]);
485
+ }
486
+ else if (extractionProvider === 'openai') {
487
+ extractionModel = await choose('Extraction model?', [
488
+ { label: 'gpt-4.1-mini', value: 'gpt-4.1-mini', desc: 'Balanced speed and quality' },
489
+ {
490
+ label: 'gpt-4.1-nano',
491
+ value: 'gpt-4.1-nano',
492
+ desc: 'Cheapest, slightly less reliable on complex schemas',
493
+ },
494
+ ]);
495
+ }
496
+ else {
497
+ // Anthropic — single model
498
+ extractionModel = 'claude-haiku-4-5-20251001';
499
+ info(`Extraction model: ${c.bold}claude-haiku-4-5-20251001${c.reset} ${c.dim}Fast, top-tier quality${c.reset}`);
500
+ }
501
+ appendToEnvFile('KEYOKU_EXTRACTION_MODEL', extractionModel);
502
+ success(`Extraction → ${c.bold}${extractionProvider}/${extractionModel}${c.reset}`);
503
+ log(`${c.dim}More models coming soon — re-run init to update.${c.reset}`);
504
+ // ── 5d: API keys ────────────────────────────────────────────────────
505
+ // Collect the unique providers that need keys
506
+ const neededProviders = new Set([embeddingProvider, extractionProvider]);
507
+ for (const provider of neededProviders) {
508
+ if (provider === 'gemini' && !hasGemini) {
509
+ const key = await prompt('Gemini API key:');
510
+ if (key) {
511
+ appendToEnvFile('GEMINI_API_KEY', key);
512
+ success('Gemini API key saved');
513
+ }
514
+ else {
515
+ warn('No key provided — set GEMINI_API_KEY manually');
516
+ }
517
+ }
518
+ else if (provider === 'openai' && !hasOpenAI) {
519
+ const key = await prompt('OpenAI API key (sk-...):');
520
+ if (key && key.startsWith('sk-')) {
521
+ appendToEnvFile('OPENAI_API_KEY', key);
522
+ success('OpenAI API key saved');
523
+ }
524
+ else {
525
+ warn('Invalid key — set OPENAI_API_KEY manually');
526
+ }
527
+ }
528
+ else if (provider === 'anthropic' && !hasAnthropic) {
529
+ const key = await prompt('Anthropic API key (sk-ant-...):');
530
+ if (key && key.startsWith('sk-ant-')) {
531
+ appendToEnvFile('ANTHROPIC_API_KEY', key);
532
+ success('Anthropic API key saved');
533
+ }
534
+ else {
535
+ warn('Invalid key — set ANTHROPIC_API_KEY manually');
536
+ }
537
+ }
538
+ }
472
539
  }
473
540
  // ── Environment File Management ──────────────────────────────────────────
474
541
  /**
@@ -578,7 +645,7 @@ async function setupTimezoneAndQuietHours() {
578
645
  console.log('');
579
646
  const tzAnswer = await prompt(`Timezone? [1-${tzOptions.length}]:`);
580
647
  const tzIdx = parseInt(tzAnswer, 10) - 1;
581
- const timezone = (tzIdx >= 0 && tzIdx < tzOptions.length) ? tzOptions[tzIdx].value : detected;
648
+ const timezone = tzIdx >= 0 && tzIdx < tzOptions.length ? tzOptions[tzIdx].value : detected;
582
649
  appendToEnvFile('KEYOKU_QUIET_HOURS_TIMEZONE', timezone);
583
650
  success(`Timezone → ${c.bold}${timezone}${c.reset}`);
584
651
  // Quiet hours
@@ -606,6 +673,172 @@ async function setupTimezoneAndQuietHours() {
606
673
  }
607
674
  success(`Quiet hours → ${c.bold}${isNaN(start) ? 23 : start}:00 – ${isNaN(end) ? 7 : end}:00${c.reset} (${timezone})`);
608
675
  }
676
+ // ── Heartbeat Delivery Setup ─────────────────────────────────────────────
677
+ /**
678
+ * Platform-specific instructions for finding group chat IDs.
679
+ */
680
+ const GROUP_ID_HINTS = {
681
+ telegram: 'Add @RawDataBot to your group — it replies with the chat ID (a negative number like -4970078838)',
682
+ discord: 'Right-click your channel → Copy Channel ID (enable Developer Mode in Settings → Advanced first)',
683
+ slack: 'Open channel details → scroll to the bottom → Channel ID',
684
+ whatsapp: 'Group JID (shown in WhatsApp Web URL or API logs)',
685
+ googlechat: 'Space ID from the URL (spaces/<id>)',
686
+ msteams: 'Channel ID from Teams admin or Graph API',
687
+ signal: 'Group ID from Signal API logs',
688
+ };
689
+ /**
690
+ * Set up heartbeat delivery in openclaw.json.
691
+ *
692
+ * Detects configured channels, asks for a group chat ID,
693
+ * and writes agents.defaults.heartbeat with target + to.
694
+ */
695
+ async function setupHeartbeatDelivery(config) {
696
+ // Detect configured channels from openclaw.json
697
+ const channels = config.channels;
698
+ const configuredChannels = channels ? Object.keys(channels).filter((k) => k !== 'defaults') : [];
699
+ if (configuredChannels.length === 0) {
700
+ log('No messaging channels configured in openclaw.json');
701
+ log("Heartbeat will run but messages won't be delivered externally");
702
+ log(`${c.dim}Configure a channel (telegram, discord, etc.) and re-run init${c.reset}`);
703
+ return;
704
+ }
705
+ info(`Detected channel(s): ${c.bold}${configuredChannels.join(', ')}${c.reset}`);
706
+ console.log('');
707
+ log('Heartbeat lets your agent proactively message you when something needs attention.');
708
+ log(`${c.yellow}Heartbeats deliver to group chats only — DMs are blocked by OpenClaw.${c.reset}`);
709
+ const enableDelivery = await choose('Set up heartbeat delivery now?', [
710
+ { label: 'Yes', value: 'yes', desc: 'configure group chat delivery' },
711
+ { label: 'Skip', value: 'no', desc: 'heartbeat runs but no messages sent' },
712
+ ]);
713
+ if (enableDelivery === 'no') {
714
+ // Write target: "none" explicitly so it's clear in config
715
+ ensureAgentsDefaults(config);
716
+ config.agents.defaults.heartbeat = {
717
+ ...config.agents.defaults.heartbeat,
718
+ every: '30m',
719
+ target: 'none',
720
+ };
721
+ writeOpenClawConfig(config);
722
+ success('Heartbeat delivery → none (runs silently)');
723
+ return;
724
+ }
725
+ // Pick channel
726
+ let targetChannel;
727
+ if (configuredChannels.length === 1) {
728
+ targetChannel = configuredChannels[0];
729
+ info(`Using ${c.bold}${targetChannel}${c.reset} (only configured channel)`);
730
+ }
731
+ else {
732
+ const channelOptions = configuredChannels.map((ch) => ({
733
+ label: ch.charAt(0).toUpperCase() + ch.slice(1),
734
+ value: ch,
735
+ }));
736
+ targetChannel = await choose('Which channel should receive heartbeat messages?', channelOptions);
737
+ }
738
+ // Get group chat ID
739
+ const hint = GROUP_ID_HINTS[targetChannel];
740
+ console.log('');
741
+ log(`Enter the ${c.bold}group chat ID${c.reset} for ${targetChannel}.`);
742
+ if (hint) {
743
+ log(`${c.dim}Tip: ${hint}${c.reset}`);
744
+ }
745
+ log(`${c.yellow}Must be a group/channel — DMs will not work.${c.reset}`);
746
+ console.log('');
747
+ const groupId = await prompt(`${targetChannel} group chat ID:`);
748
+ if (!groupId) {
749
+ warn('No group ID provided — heartbeat delivery disabled');
750
+ ensureAgentsDefaults(config);
751
+ config.agents.defaults.heartbeat = {
752
+ ...config.agents.defaults.heartbeat,
753
+ every: '30m',
754
+ target: 'none',
755
+ };
756
+ writeOpenClawConfig(config);
757
+ return;
758
+ }
759
+ // Pick interval
760
+ const interval = await choose('Heartbeat interval?', [
761
+ { label: '15 minutes', value: '15m', desc: 'balanced' },
762
+ { label: '30 minutes', value: '30m', desc: 'default, lower cost' },
763
+ { label: '1 hour', value: '1h', desc: 'minimal cost' },
764
+ { label: '5 minutes', value: '5m', desc: 'frequent, higher cost' },
765
+ ]);
766
+ // Write to openclaw.json
767
+ ensureAgentsDefaults(config);
768
+ config.agents.defaults.heartbeat = {
769
+ ...config.agents.defaults.heartbeat,
770
+ every: interval,
771
+ target: targetChannel,
772
+ to: groupId,
773
+ };
774
+ writeOpenClawConfig(config);
775
+ success(`Heartbeat → ${c.bold}${targetChannel}${c.reset} (group: ${c.dim}${groupId}${c.reset}, every ${interval})`);
776
+ }
777
+ /**
778
+ * Ensure agents.defaults exists in config.
779
+ */
780
+ function ensureAgentsDefaults(config) {
781
+ if (!config.agents)
782
+ config.agents = {};
783
+ if (!config.agents.defaults)
784
+ config.agents.defaults = {};
785
+ if (!config.agents.defaults.heartbeat)
786
+ config.agents.defaults.heartbeat = {};
787
+ }
788
+ // ── Start Keyoku for Migration ──────────────────────────────────────────
789
+ /**
790
+ * Temporarily start the keyoku binary so migration can call the API.
791
+ * Returns a cleanup function to kill the process afterward.
792
+ */
793
+ async function startKeyokuForMigration() {
794
+ const { spawn } = await import('node:child_process');
795
+ const { randomBytes } = await import('node:crypto');
796
+ // Check if already running
797
+ if (await waitForHealthy('http://localhost:18900', 2000, 500)) {
798
+ info('Keyoku already running');
799
+ return () => { }; // no-op cleanup
800
+ }
801
+ const binary = findKeyokuBinary();
802
+ if (!binary) {
803
+ warn('Keyoku binary not found — migration will be skipped');
804
+ return () => { };
805
+ }
806
+ const keyokuEnv = loadKeyokuEnv();
807
+ const env = { ...keyokuEnv, ...process.env };
808
+ if (!env.KEYOKU_SESSION_TOKEN) {
809
+ env.KEYOKU_SESSION_TOKEN = randomBytes(16).toString('hex');
810
+ }
811
+ process.env.KEYOKU_SESSION_TOKEN = env.KEYOKU_SESSION_TOKEN;
812
+ if (!env.KEYOKU_DB_PATH) {
813
+ const dbDir = join(HOME, '.keyoku', 'data');
814
+ mkdirSync(dbDir, { recursive: true });
815
+ env.KEYOKU_DB_PATH = join(dbDir, 'keyoku.db');
816
+ }
817
+ info('Starting keyoku for migration...');
818
+ const proc = spawn(binary, [], {
819
+ stdio: ['ignore', 'pipe', 'pipe'],
820
+ detached: false,
821
+ env,
822
+ });
823
+ // Suppress output during migration
824
+ proc.stdout?.resume();
825
+ proc.stderr?.resume();
826
+ const healthy = await waitForHealthy('http://localhost:18900', 10000, 500);
827
+ if (healthy) {
828
+ info('Keyoku ready for migration');
829
+ }
830
+ else {
831
+ warn('Keyoku health check timed out — migration may fail');
832
+ }
833
+ return () => {
834
+ try {
835
+ proc.kill('SIGTERM');
836
+ }
837
+ catch {
838
+ // Process already exited
839
+ }
840
+ };
841
+ }
609
842
  // ── Health Check ─────────────────────────────────────────────────────────
610
843
  /**
611
844
  * Run a health check against keyoku-engine to verify the install works.
@@ -722,65 +955,92 @@ export async function init() {
722
955
  // Step 7: Timezone & quiet hours
723
956
  stepHeader('Timezone & Quiet Hours');
724
957
  await setupTimezoneAndQuietHours();
725
- // Step 8: SKILL.md
958
+ // Step 8: Heartbeat delivery
959
+ stepHeader('Heartbeat Delivery');
960
+ await setupHeartbeatDelivery(config);
961
+ // Step 9: SKILL.md
726
962
  stepHeader('Install Skill Guide');
727
963
  installSkill();
728
- // Step 8b: HEARTBEAT.md
729
- installHeartbeatMd();
730
- // Step 9: Migration
964
+ // Step 9b: HEARTBEAT.md (extract user rules before writing keyoku section)
965
+ const heartbeatRules = installHeartbeatMd();
966
+ // Step 10: Migration
731
967
  const memoryMdPath = join(HOME, '.openclaw', 'MEMORY.md');
732
968
  const hasMemoryMd = existsSync(memoryMdPath);
733
969
  const vectorDbs = discoverVectorDbs(OPENCLAW_MEMORY_DIR);
734
970
  const hasVectorStores = vectorDbs.length > 0;
735
- if (hasMemoryMd || hasVectorStores) {
971
+ const hasHeartbeatRules = heartbeatRules.length > 0;
972
+ if (hasMemoryMd || hasVectorStores || hasHeartbeatRules) {
736
973
  stepHeader('Migrate Memories');
737
974
  if (hasMemoryMd)
738
975
  info('Found MEMORY.md');
739
976
  if (hasVectorStores)
740
977
  info(`Found ${vectorDbs.length} vector store(s)`);
978
+ if (hasHeartbeatRules)
979
+ info(`Found ${heartbeatRules.length} heartbeat rule(s)`);
741
980
  const migrate = await choose('Migrate existing memories into Keyoku?', [
742
981
  { label: 'Yes', value: 'yes', desc: 'import everything now' },
743
982
  { label: 'No', value: 'no', desc: 'skip for now' },
744
983
  ]);
745
984
  if (migrate === 'yes') {
746
- info('Starting migration...');
747
- const client = new KeyokuClient({
748
- baseUrl: 'http://localhost:18900',
749
- token: process.env.KEYOKU_SESSION_TOKEN,
750
- timeout: 60000,
751
- });
752
- const entityId = 'default';
753
- // Migrate markdown files
754
- if (hasMemoryMd) {
755
- try {
756
- const mdResult = await importMemoryFiles({
757
- client,
758
- entityId,
759
- workspaceDir: join(HOME, '.openclaw'),
760
- logger: console,
761
- });
762
- success(`Markdown: ${mdResult.imported} imported, ${mdResult.skipped} skipped`);
763
- }
764
- catch (err) {
765
- warn(`Markdown migration failed: ${String(err)}`);
766
- log('Make sure Keyoku is running (it auto-starts when OpenClaw loads the plugin)');
985
+ // Start keyoku temporarily for migration
986
+ const cleanup = await startKeyokuForMigration();
987
+ try {
988
+ const client = new KeyokuClient({
989
+ baseUrl: 'http://localhost:18900',
990
+ token: process.env.KEYOKU_SESSION_TOKEN,
991
+ timeout: 60000,
992
+ });
993
+ const entityId = 'default';
994
+ // Migrate markdown files
995
+ if (hasMemoryMd) {
996
+ try {
997
+ const mdResult = await importMemoryFiles({
998
+ client,
999
+ entityId,
1000
+ workspaceDir: join(HOME, '.openclaw'),
1001
+ logger: console,
1002
+ });
1003
+ success(`Markdown: ${mdResult.imported} imported, ${mdResult.skipped} skipped`);
1004
+ }
1005
+ catch (err) {
1006
+ warn(`Markdown migration failed: ${String(err)}`);
1007
+ }
767
1008
  }
768
- }
769
- // Migrate vector stores
770
- if (hasVectorStores) {
771
- try {
772
- const vsResult = await migrateAllVectorStores({
773
- client,
774
- entityId,
775
- memoryDir: OPENCLAW_MEMORY_DIR,
776
- logger: console,
777
- });
778
- success(`Vector stores: ${vsResult.imported} imported, ${vsResult.skipped} skipped`);
1009
+ // Migrate vector stores
1010
+ if (hasVectorStores) {
1011
+ try {
1012
+ const vsResult = await migrateAllVectorStores({
1013
+ client,
1014
+ entityId,
1015
+ memoryDir: OPENCLAW_MEMORY_DIR,
1016
+ logger: console,
1017
+ });
1018
+ success(`Vector stores: ${vsResult.imported} imported, ${vsResult.skipped} skipped`);
1019
+ }
1020
+ catch (err) {
1021
+ warn(`Vector store migration failed: ${String(err)}`);
1022
+ }
779
1023
  }
780
- catch (err) {
781
- warn(`Vector store migration failed: ${String(err)}`);
1024
+ // Migrate heartbeat rules (preferences, rules, scheduled tasks)
1025
+ if (hasHeartbeatRules) {
1026
+ try {
1027
+ const hbResult = await migrateHeartbeatRules({
1028
+ client,
1029
+ entityId,
1030
+ agentId: 'default',
1031
+ rules: heartbeatRules,
1032
+ logger: console,
1033
+ });
1034
+ success(`Heartbeat: ${hbResult.preferences} preferences, ${hbResult.rules} rules, ${hbResult.schedules} schedules`);
1035
+ }
1036
+ catch (err) {
1037
+ warn(`Heartbeat migration failed: ${String(err)}`);
1038
+ }
782
1039
  }
783
1040
  }
1041
+ finally {
1042
+ cleanup();
1043
+ }
784
1044
  }
785
1045
  else {
786
1046
  log('Skipping — you can re-run init later to migrate');
@@ -790,7 +1050,7 @@ export async function init() {
790
1050
  stepHeader('Migrate Memories');
791
1051
  log('No existing memories found — nothing to migrate');
792
1052
  }
793
- // Step 10: Health check
1053
+ // Step 11: Health check
794
1054
  stepHeader('Health Check');
795
1055
  await healthCheck();
796
1056
  // Close readline before exiting
@@ -815,6 +1075,10 @@ export async function init() {
815
1075
  console.log(` ${c.dim}openclaw memory status${c.reset} ${c.dim}check memory index status${c.reset}`);
816
1076
  console.log(` ${c.dim}openclaw memory search${c.reset} ${c.dim}search stored memories${c.reset}`);
817
1077
  console.log('');
1078
+ console.log(` ${c.gray}3.${c.reset} ${c.yellow}Heartbeat requires a group chat${c.reset}`);
1079
+ console.log(` ${c.dim}OpenClaw delivers heartbeats to group chats only (not DMs).${c.reset}`);
1080
+ console.log(` ${c.dim}Add your bot to a Telegram/Discord/WhatsApp group to receive proactive check-ins.${c.reset}`);
1081
+ console.log('');
818
1082
  console.log(` ${c.indigo}${'━'.repeat(52)}${c.reset}`);
819
1083
  console.log('');
820
1084
  }