@ghl-ai/aw 0.1.37-beta.41 → 0.1.37-beta.43

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 (2) hide show
  1. package/commands/memory.mjs +370 -1
  2. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
- // commands/memory.mjs — `aw memory [store|search|pack|stats|validate|invalidate|sync|audit]`
1
+ // commands/memory.mjs — `aw memory [store|search|pack|stats|validate|invalidate|sync|audit|review|promote]`
2
2
 
3
3
  import { join } from 'node:path';
4
+ import { readFileSync, existsSync } from 'node:fs';
4
5
  import { homedir } from 'node:os';
6
+ import { confirm, isCancel } from '@clack/prompts';
5
7
  import * as fmt from '../fmt.mjs';
6
8
  import { chalk } from '../fmt.mjs';
7
9
  import { callMemoryTool } from '../memory-bridge.mjs';
@@ -19,6 +21,8 @@ export async function memoryCommand(args) {
19
21
  case 'invalidate': return memoryInvalidate(args);
20
22
  case 'sync': return memorySync(args);
21
23
  case 'audit': return memoryAudit(args);
24
+ case 'review': return memoryReview(args);
25
+ case 'promote': return memoryPromote(args);
22
26
  default: return memoryHelp();
23
27
  }
24
28
  }
@@ -461,6 +465,363 @@ function getQualityIssue(content) {
461
465
  return 'Low specificity';
462
466
  }
463
467
 
468
+ // ── review ───────────────────────────────────────────────────────────
469
+
470
+ async function memoryReview(args) {
471
+ fmt.intro('aw memory review');
472
+
473
+ const threshold = parseFloat(args['--threshold'] || '0.6');
474
+ const limit = parseInt(args['--limit'] || '50', 10);
475
+
476
+ const s = fmt.spinner();
477
+ s.start('Fetching memories for review...');
478
+
479
+ try {
480
+ const result = await callMemoryTool('memory_search', { query: '*', limit });
481
+ const allMemories = Array.isArray(result) ? result : (result?.memories ?? result?.results ?? []);
482
+
483
+ if (allMemories.length === 0) {
484
+ s.stop('No memories found');
485
+ fmt.outro('Nothing to review');
486
+ return;
487
+ }
488
+
489
+ // Filter for low-confidence memories
490
+ const needsReview = allMemories.filter(mem => {
491
+ const conf = mem.classification_confidence ?? mem.confidence;
492
+ // If confidence field exists, use the threshold; otherwise include it as "unknown confidence"
493
+ if (conf != null) return conf < threshold;
494
+ // No confidence field — treat as needing review (proxy: confidence unknown)
495
+ return true;
496
+ });
497
+
498
+ s.stop(`Found ${needsReview.length} memor${needsReview.length === 1 ? 'y' : 'ies'} to review (of ${allMemories.length} fetched)`);
499
+
500
+ if (needsReview.length === 0) {
501
+ fmt.logInfo('All memories have confidence above threshold. Nothing to review.');
502
+ fmt.outro('Review complete');
503
+ return;
504
+ }
505
+
506
+ let validated = 0;
507
+ let invalidated = 0;
508
+ let skipped = 0;
509
+
510
+ for (let i = 0; i < needsReview.length; i++) {
511
+ const mem = needsReview[i];
512
+ const content = mem.content ?? mem.text ?? JSON.stringify(mem);
513
+ const conf = mem.classification_confidence ?? mem.confidence;
514
+ const overlays = Array.isArray(mem.overlay) ? mem.overlay.join(', ') : (mem.overlay || '-');
515
+ const angles = Array.isArray(mem.angle) ? mem.angle.join(', ') : (mem.angle || '-');
516
+
517
+ const infoLines = [
518
+ `${chalk.dim('content:')} ${content}`,
519
+ `${chalk.dim('layer:')} ${chalk.cyan(mem.layer || 'unclassified')}`,
520
+ `${chalk.dim('overlay:')} ${chalk.magenta(overlays)}`,
521
+ `${chalk.dim('angle:')} ${chalk.yellow(angles)}`,
522
+ `${chalk.dim('confidence:')} ${conf != null ? (conf < threshold ? chalk.red(conf) : chalk.green(conf)) : chalk.dim('unknown')}`,
523
+ mem.id ? `${chalk.dim('id:')} ${mem.id}` : null,
524
+ mem.type ? `${chalk.dim('type:')} ${mem.type}` : null,
525
+ ].filter(Boolean).join('\n');
526
+
527
+ fmt.note(infoLines, `Memory ${i + 1}/${needsReview.length}`);
528
+
529
+ const action = await fmt.select({
530
+ message: 'What would you like to do with this memory?',
531
+ options: [
532
+ { value: 'confirm', label: 'Confirm', hint: 'validate this memory' },
533
+ { value: 'invalidate', label: 'Invalidate', hint: 'mark as invalid' },
534
+ { value: 'skip', label: 'Skip', hint: 'move to next' },
535
+ { value: 'quit', label: 'Quit review', hint: 'stop reviewing' },
536
+ ],
537
+ });
538
+
539
+ if (fmt.isCancel(action) || action === 'quit') {
540
+ fmt.logInfo('Review stopped by user.');
541
+ break;
542
+ }
543
+
544
+ if (action === 'skip') {
545
+ skipped++;
546
+ continue;
547
+ }
548
+
549
+ if (!mem.id) {
550
+ fmt.logWarn('Memory has no ID — cannot send feedback. Skipping.');
551
+ skipped++;
552
+ continue;
553
+ }
554
+
555
+ const feedbackType = action === 'confirm' ? 'validate' : 'invalidate';
556
+
557
+ try {
558
+ await callMemoryTool('memory_feedback', {
559
+ memory_id: mem.id,
560
+ feedback_type: feedbackType,
561
+ actor_type: 'user',
562
+ });
563
+
564
+ if (action === 'confirm') {
565
+ validated++;
566
+ fmt.logSuccess(`Memory ${mem.id.slice(0, 8)} validated`);
567
+ } else {
568
+ invalidated++;
569
+ fmt.logWarn(`Memory ${mem.id.slice(0, 8)} invalidated`);
570
+ }
571
+ } catch (err) {
572
+ fmt.logError(`Feedback failed for ${mem.id.slice(0, 8)}: ${err.message}`);
573
+ skipped++;
574
+ }
575
+ }
576
+
577
+ const summaryLines = [
578
+ ` ${chalk.green('Validated:')} ${validated}`,
579
+ ` ${chalk.red('Invalidated:')} ${invalidated}`,
580
+ ` ${chalk.dim('Skipped:')} ${skipped}`,
581
+ ].join('\n');
582
+ fmt.note(summaryLines, 'Review Summary');
583
+ fmt.outro('Review complete');
584
+ } catch (err) {
585
+ s.stop(chalk.red('Failed'));
586
+ fmt.cancel(`Review failed: ${err.message}`);
587
+ }
588
+ }
589
+
590
+ // ── promote ──────────────────────────────────────────────────────────
591
+
592
+ async function memoryPromote(args) {
593
+ fmt.intro('aw memory promote');
594
+
595
+ const limit = parseInt(args['--limit'] || '200', 10);
596
+ const minOccurrences = parseInt(args['--min'] || '3', 10);
597
+ const cwd = process.cwd();
598
+
599
+ // Step 1: Find the project matching cwd from homunculus projects.json
600
+ const projectsPath = join(homedir(), '.claude', 'homunculus', 'projects.json');
601
+ if (!existsSync(projectsPath)) {
602
+ fmt.cancel('No homunculus projects.json found at ' + projectsPath);
603
+ return;
604
+ }
605
+
606
+ let projects;
607
+ try {
608
+ projects = JSON.parse(readFileSync(projectsPath, 'utf8'));
609
+ } catch (err) {
610
+ fmt.cancel(`Failed to parse projects.json: ${err.message}`);
611
+ return;
612
+ }
613
+
614
+ // projects.json maps hashes to { name, root, remote, ... }
615
+ const entries = Object.entries(projects);
616
+ const match = entries.find(([, proj]) => {
617
+ const root = proj.root || proj.path;
618
+ if (!root) return false;
619
+ // Match if cwd starts with the project root (handles subdirectories)
620
+ return cwd === root || cwd.startsWith(root + '/');
621
+ });
622
+
623
+ if (!match) {
624
+ fmt.cancel(`No homunculus project found matching cwd: ${cwd}`);
625
+ fmt.logInfo('Known project roots:');
626
+ for (const [hash, proj] of entries) {
627
+ fmt.logInfo(` ${chalk.dim(hash.slice(0, 8))} ${proj.root || proj.path || '?'} ${chalk.cyan(proj.name || '')}`);
628
+ }
629
+ return;
630
+ }
631
+
632
+ const [projectHash, projectInfo] = match;
633
+ const projectName = projectInfo.name || projectHash.slice(0, 8);
634
+ fmt.logStep(`Project: ${chalk.cyan(projectName)} ${chalk.dim(`(${projectHash.slice(0, 8)})`)}`);
635
+
636
+ // Step 2: Read observations.jsonl
637
+ const observationsPath = join(homedir(), '.claude', 'homunculus', 'projects', projectHash, 'observations.jsonl');
638
+ if (!existsSync(observationsPath)) {
639
+ fmt.cancel(`No observations file found at ${observationsPath}`);
640
+ return;
641
+ }
642
+
643
+ let lines;
644
+ try {
645
+ const raw = readFileSync(observationsPath, 'utf8');
646
+ lines = raw.trim().split('\n').filter(Boolean);
647
+ } catch (err) {
648
+ fmt.cancel(`Failed to read observations: ${err.message}`);
649
+ return;
650
+ }
651
+
652
+ if (lines.length === 0) {
653
+ fmt.cancel('Observations file is empty.');
654
+ return;
655
+ }
656
+
657
+ // Take the last N observations
658
+ const recentLines = lines.slice(-limit);
659
+ fmt.logStep(`Read ${recentLines.length} observations (of ${lines.length} total)`);
660
+
661
+ // Step 3: Parse and extract tool + outcome patterns
662
+ const observations = [];
663
+ for (const line of recentLines) {
664
+ try {
665
+ observations.push(JSON.parse(line));
666
+ } catch {
667
+ // skip malformed lines
668
+ }
669
+ }
670
+
671
+ if (observations.length === 0) {
672
+ fmt.cancel('No valid observations found.');
673
+ return;
674
+ }
675
+
676
+ // Group by tool name + outcome (success/failure)
677
+ const patternCounts = new Map(); // "tool:outcome" -> count
678
+ const patternSessions = new Map(); // "tool:outcome" -> Set<sessionId>
679
+ const patternExamples = new Map(); // "tool:outcome" -> first example observation
680
+
681
+ for (const obs of observations) {
682
+ const tool = obs.tool || obs.toolName || obs.tool_name || 'unknown';
683
+ const outcome = obs.outcome || obs.status || (obs.error ? 'failure' : 'success');
684
+ const session = obs.session || obs.sessionId || obs.session_id || 'default';
685
+ const key = `${tool}:${outcome}`;
686
+
687
+ patternCounts.set(key, (patternCounts.get(key) || 0) + 1);
688
+
689
+ if (!patternSessions.has(key)) patternSessions.set(key, new Set());
690
+ patternSessions.get(key).add(session);
691
+
692
+ if (!patternExamples.has(key)) patternExamples.set(key, obs);
693
+ }
694
+
695
+ // Step 4: Filter for high-frequency patterns (appearing minOccurrences+ times across 2+ sessions)
696
+ const promotable = [];
697
+ for (const [key, count] of patternCounts) {
698
+ const sessionCount = patternSessions.get(key).size;
699
+ if (count >= minOccurrences && sessionCount >= 2) {
700
+ const [tool, outcome] = key.split(':');
701
+ const example = patternExamples.get(key);
702
+ promotable.push({ tool, outcome, count, sessionCount, example });
703
+ }
704
+ }
705
+
706
+ // Sort by count descending
707
+ promotable.sort((a, b) => b.count - a.count);
708
+
709
+ if (promotable.length === 0) {
710
+ fmt.logInfo(`No patterns found with ${minOccurrences}+ occurrences across 2+ sessions.`);
711
+ fmt.logInfo('Try lowering the threshold with --min <n>.');
712
+
713
+ // Show top patterns anyway for visibility
714
+ const topPatterns = [...patternCounts.entries()]
715
+ .sort((a, b) => b[1] - a[1])
716
+ .slice(0, 10);
717
+ if (topPatterns.length > 0) {
718
+ const topLines = topPatterns
719
+ .map(([key, count]) => {
720
+ const sessions = patternSessions.get(key).size;
721
+ const [tool, outcome] = key.split(':');
722
+ const outcomeColor = outcome === 'success' ? chalk.green(outcome) : chalk.red(outcome);
723
+ return ` ${chalk.cyan(tool.padEnd(30))} ${outcomeColor.padEnd(20)} ${chalk.dim(`${count}x, ${sessions} session${sessions !== 1 ? 's' : ''}`)}`;
724
+ })
725
+ .join('\n');
726
+ fmt.note(topLines, 'Top patterns (below threshold)');
727
+ }
728
+ fmt.outro('Nothing to promote');
729
+ return;
730
+ }
731
+
732
+ // Step 5: Show summary of what would be promoted
733
+ const summaryLines = promotable.map(p => {
734
+ const outcomeColor = p.outcome === 'success' ? chalk.green(p.outcome) : chalk.red(p.outcome);
735
+ return ` ${chalk.cyan(p.tool.padEnd(30))} ${outcomeColor.padEnd(20)} ${chalk.dim(`${p.count}x across ${p.sessionCount} sessions`)}`;
736
+ }).join('\n');
737
+
738
+ fmt.note(summaryLines, `${promotable.length} pattern${promotable.length !== 1 ? 's' : ''} to promote`);
739
+
740
+ // Step 6: Ask for confirmation (skip if --yes flag)
741
+ if (!args['--yes'] && !args['-y']) {
742
+ try {
743
+ const shouldPromote = await confirm({
744
+ message: `Promote ${promotable.length} pattern${promotable.length !== 1 ? 's' : ''} to team memory?`,
745
+ });
746
+ if (isCancel(shouldPromote) || !shouldPromote) {
747
+ fmt.logInfo('Promotion cancelled.');
748
+ fmt.outro('No patterns promoted');
749
+ return;
750
+ }
751
+ } catch {
752
+ // TTY not available (e.g., running inside Claude Code) — proceed
753
+ }
754
+ }
755
+
756
+ // Step 7: Promote each pattern via memory_store
757
+ const s = fmt.spinner();
758
+ s.start('Promoting patterns to team memory...');
759
+
760
+ let promoted = 0;
761
+ let failed = 0;
762
+
763
+ for (const p of promotable) {
764
+ const content = buildPromoteContent(p, projectName);
765
+
766
+ try {
767
+ await callMemoryTool('memory_store', {
768
+ content,
769
+ type: 'pattern',
770
+ curate: true,
771
+ source: 'instinct-promotion',
772
+ });
773
+ promoted++;
774
+ } catch (err) {
775
+ failed++;
776
+ fmt.logError(`Failed to promote ${p.tool}:${p.outcome}: ${err.message}`);
777
+ }
778
+ }
779
+
780
+ s.stop('Promotion complete');
781
+
782
+ // Step 8: Report
783
+ const resultLines = [
784
+ ` ${chalk.green('Promoted:')} ${promoted}`,
785
+ failed > 0 ? ` ${chalk.red('Failed:')} ${failed}` : null,
786
+ ` ${chalk.dim('Project:')} ${projectName}`,
787
+ ` ${chalk.dim('Source:')} ${observationsPath}`,
788
+ ].filter(Boolean).join('\n');
789
+
790
+ fmt.note(resultLines, 'Promote Summary');
791
+ fmt.outro(`${promoted} pattern${promoted !== 1 ? 's' : ''} promoted to team memory`);
792
+ }
793
+
794
+ /**
795
+ * Build a human-readable memory content string from a tool pattern.
796
+ */
797
+ function buildPromoteContent(pattern, projectName) {
798
+ const { tool, outcome, count, sessionCount, example } = pattern;
799
+ const parts = [
800
+ `[${projectName}] Tool pattern: "${tool}" consistently ${outcome === 'success' ? 'succeeds' : 'fails'}.`,
801
+ `Observed ${count} times across ${sessionCount} sessions.`,
802
+ ];
803
+
804
+ // Add context from the example observation if available
805
+ if (example) {
806
+ if (example.input || example.args) {
807
+ const input = example.input || example.args;
808
+ const inputStr = typeof input === 'string' ? input : JSON.stringify(input);
809
+ if (inputStr.length <= 200) {
810
+ parts.push(`Example input: ${inputStr}`);
811
+ }
812
+ }
813
+ if (outcome !== 'success' && (example.error || example.errorMessage || example.error_message)) {
814
+ const errMsg = example.error || example.errorMessage || example.error_message;
815
+ const errStr = typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg);
816
+ if (errStr.length <= 200) {
817
+ parts.push(`Common error: ${errStr}`);
818
+ }
819
+ }
820
+ }
821
+
822
+ return parts.join(' ');
823
+ }
824
+
464
825
  // ── help ─────────────────────────────────────────────────────────────
465
826
 
466
827
  function memoryHelp() {
@@ -508,6 +869,14 @@ function memoryHelp() {
508
869
  cmd(' --sample <n>', 'Sample size (default: 30)'),
509
870
  cmd(' --days <n>', 'Days to look back (default: 30)'),
510
871
  '',
872
+ cmd('aw memory review', 'Interactively review low-confidence memories'),
873
+ cmd(' --threshold <n>', 'Confidence threshold (default: 0.6)'),
874
+ cmd(' --limit <n>', 'Max memories to fetch (default: 50)'),
875
+ '',
876
+ cmd('aw memory promote', 'Promote local instincts to team memory'),
877
+ cmd(' --limit <n>', 'Observations to scan (default: 200)'),
878
+ cmd(' --min <n>', 'Min occurrences to promote (default: 3)'),
879
+ '',
511
880
  ].join('\n');
512
881
 
513
882
  console.log(help);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.37-beta.41",
3
+ "version": "0.1.37-beta.43",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": "bin.js",