@ghl-ai/aw 0.1.37-beta.43 → 0.1.37-beta.44
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/commands/memory.mjs +388 -8
- package/memory-bridge.mjs +75 -1
- package/memory-queue.mjs +128 -0
- package/package.json +3 -2
package/commands/memory.mjs
CHANGED
|
@@ -1,16 +1,132 @@
|
|
|
1
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
|
+
import { readFileSync, existsSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { confirm, isCancel } from '@clack/prompts';
|
|
6
|
+
import { confirm, isCancel, text } from '@clack/prompts';
|
|
7
7
|
import * as fmt from '../fmt.mjs';
|
|
8
8
|
import { chalk } from '../fmt.mjs';
|
|
9
9
|
import { callMemoryTool } from '../memory-bridge.mjs';
|
|
10
10
|
import { syncMemories } from '../memory-sync.mjs';
|
|
11
11
|
import { REGISTRY_DIR } from '../constants.mjs';
|
|
12
|
+
import { getQueueStats } from '../memory-queue.mjs';
|
|
13
|
+
|
|
14
|
+
// ── legacy import ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One-time import of learned skills from ~/.claude/skills/learned/*.md
|
|
18
|
+
* into the memory system. Runs silently if nothing to import.
|
|
19
|
+
*/
|
|
20
|
+
async function checkAndRunLegacyImport() {
|
|
21
|
+
const awDir = join(homedir(), '.aw');
|
|
22
|
+
const flagFile = join(awDir, 'memory-migrated');
|
|
23
|
+
|
|
24
|
+
// Already migrated — bail immediately
|
|
25
|
+
if (existsSync(flagFile)) return;
|
|
26
|
+
|
|
27
|
+
const skillsDir = join(homedir(), '.claude', 'skills', 'learned');
|
|
28
|
+
|
|
29
|
+
// No skills directory — mark as migrated and bail
|
|
30
|
+
if (!existsSync(skillsDir)) {
|
|
31
|
+
mkdirSync(awDir, { recursive: true });
|
|
32
|
+
writeFileSync(flagFile, JSON.stringify({ migrated_at: new Date().toISOString(), count: 0, skipped: 0 }, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let files;
|
|
37
|
+
try {
|
|
38
|
+
files = readdirSync(skillsDir).filter(f => f.endsWith('.md'));
|
|
39
|
+
} catch {
|
|
40
|
+
return; // Can't read directory — skip silently
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// No .md files found — mark as migrated and bail
|
|
44
|
+
if (files.length === 0) {
|
|
45
|
+
mkdirSync(awDir, { recursive: true });
|
|
46
|
+
writeFileSync(flagFile, JSON.stringify({ migrated_at: new Date().toISOString(), count: 0, skipped: 0 }, null, 2));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const s = fmt.spinner();
|
|
51
|
+
s.start(`Importing learned skills... 0/${files.length}`);
|
|
52
|
+
|
|
53
|
+
let imported = 0;
|
|
54
|
+
let skipped = 0;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < files.length; i++) {
|
|
57
|
+
const file = files[i];
|
|
58
|
+
try {
|
|
59
|
+
const raw = readFileSync(join(skillsDir, file), 'utf8');
|
|
60
|
+
|
|
61
|
+
// Parse YAML frontmatter
|
|
62
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
63
|
+
let description = '';
|
|
64
|
+
let body = '';
|
|
65
|
+
|
|
66
|
+
if (fmMatch) {
|
|
67
|
+
const frontmatter = fmMatch[1];
|
|
68
|
+
body = fmMatch[2].trim();
|
|
69
|
+
|
|
70
|
+
// Extract name and description from frontmatter
|
|
71
|
+
for (const line of frontmatter.split('\n')) {
|
|
72
|
+
const kvMatch = line.match(/^(\w+):\s*(.+)$/);
|
|
73
|
+
if (kvMatch) {
|
|
74
|
+
const [, key, value] = kvMatch;
|
|
75
|
+
if (key === 'description') {
|
|
76
|
+
description = value.replace(/^["']|["']$/g, '').trim();
|
|
77
|
+
} else if (key === 'name' && !description) {
|
|
78
|
+
description = value.replace(/^["']|["']$/g, '').trim();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// No frontmatter — use entire content as body
|
|
84
|
+
body = raw.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Build content: description + body, truncated to 1000 chars
|
|
88
|
+
const content = ((description ? description + '\n\n' : '') + body).slice(0, 1000);
|
|
89
|
+
|
|
90
|
+
if (!content) {
|
|
91
|
+
skipped++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await callMemoryTool('memory_store', {
|
|
96
|
+
content,
|
|
97
|
+
type: 'learning',
|
|
98
|
+
source: 'legacy-import',
|
|
99
|
+
curate: true,
|
|
100
|
+
confidence: 0.6,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
imported++;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
skipped++;
|
|
106
|
+
fmt.logWarn(`Skipped ${file}: ${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
s.message = `Importing learned skills... ${i + 1}/${files.length}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
s.stop(`Imported ${imported} learned skill${imported !== 1 ? 's' : ''}`);
|
|
113
|
+
|
|
114
|
+
if (imported > 0 || skipped > 0) {
|
|
115
|
+
fmt.logStep(`Imported ${imported} learned skills as memories${skipped > 0 ? ` (${skipped} skipped)` : ''}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Write flag file
|
|
119
|
+
mkdirSync(awDir, { recursive: true });
|
|
120
|
+
writeFileSync(flagFile, JSON.stringify({
|
|
121
|
+
migrated_at: new Date().toISOString(),
|
|
122
|
+
count: imported,
|
|
123
|
+
skipped,
|
|
124
|
+
}, null, 2));
|
|
125
|
+
}
|
|
12
126
|
|
|
13
127
|
export async function memoryCommand(args) {
|
|
128
|
+
await checkAndRunLegacyImport();
|
|
129
|
+
|
|
14
130
|
const sub = args._positional?.[0];
|
|
15
131
|
switch (sub) {
|
|
16
132
|
case 'store': return memoryStore(args);
|
|
@@ -21,9 +137,10 @@ export async function memoryCommand(args) {
|
|
|
21
137
|
case 'invalidate': return memoryInvalidate(args);
|
|
22
138
|
case 'sync': return memorySync(args);
|
|
23
139
|
case 'audit': return memoryAudit(args);
|
|
24
|
-
case 'review':
|
|
25
|
-
case 'promote':
|
|
26
|
-
|
|
140
|
+
case 'review': return memoryReview(args);
|
|
141
|
+
case 'promote': return memoryPromote(args);
|
|
142
|
+
case 'scope-promote': return memoryScopePromote(args);
|
|
143
|
+
default: return memoryHelp();
|
|
27
144
|
}
|
|
28
145
|
}
|
|
29
146
|
|
|
@@ -219,6 +336,17 @@ async function memoryStats(args) {
|
|
|
219
336
|
}
|
|
220
337
|
|
|
221
338
|
fmt.logStep(`Total memories: ${chalk.bold(stats.total ?? 0)}`);
|
|
339
|
+
|
|
340
|
+
// Show offline queue stats if there are pending items
|
|
341
|
+
const queueStats = getQueueStats();
|
|
342
|
+
if (queueStats.pending > 0) {
|
|
343
|
+
const queueLines = [
|
|
344
|
+
` ${chalk.yellow('Pending:'.padEnd(20))} ${queueStats.pending}`,
|
|
345
|
+
queueStats.oldest ? ` ${chalk.dim('Oldest:'.padEnd(20))} ${formatTimeAgo(queueStats.oldest)}` : null,
|
|
346
|
+
].filter(Boolean).join('\n');
|
|
347
|
+
fmt.note(queueLines, 'Offline Queue');
|
|
348
|
+
}
|
|
349
|
+
|
|
222
350
|
fmt.outro('Stats complete');
|
|
223
351
|
} catch (err) {
|
|
224
352
|
s.stop(chalk.red('Failed'));
|
|
@@ -226,6 +354,19 @@ async function memoryStats(args) {
|
|
|
226
354
|
}
|
|
227
355
|
}
|
|
228
356
|
|
|
357
|
+
/** Format an ISO timestamp as a human-readable "time ago" string. */
|
|
358
|
+
function formatTimeAgo(isoString) {
|
|
359
|
+
const diff = Date.now() - new Date(isoString).getTime();
|
|
360
|
+
const seconds = Math.floor(diff / 1000);
|
|
361
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
362
|
+
const minutes = Math.floor(seconds / 60);
|
|
363
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
364
|
+
const hours = Math.floor(minutes / 60);
|
|
365
|
+
if (hours < 24) return `${hours}h ago`;
|
|
366
|
+
const days = Math.floor(hours / 24);
|
|
367
|
+
return `${days}d ago`;
|
|
368
|
+
}
|
|
369
|
+
|
|
229
370
|
// ── validate ──────────────────────────────────────────────────────────
|
|
230
371
|
|
|
231
372
|
async function memoryValidate(args) {
|
|
@@ -505,6 +646,7 @@ async function memoryReview(args) {
|
|
|
505
646
|
|
|
506
647
|
let validated = 0;
|
|
507
648
|
let invalidated = 0;
|
|
649
|
+
let reclassified = 0;
|
|
508
650
|
let skipped = 0;
|
|
509
651
|
|
|
510
652
|
for (let i = 0; i < needsReview.length; i++) {
|
|
@@ -531,6 +673,7 @@ async function memoryReview(args) {
|
|
|
531
673
|
options: [
|
|
532
674
|
{ value: 'confirm', label: 'Confirm', hint: 'validate this memory' },
|
|
533
675
|
{ value: 'invalidate', label: 'Invalidate', hint: 'mark as invalid' },
|
|
676
|
+
{ value: 'reclassify', label: 'Reclassify', hint: 'change layer/overlay/angle' },
|
|
534
677
|
{ value: 'skip', label: 'Skip', hint: 'move to next' },
|
|
535
678
|
{ value: 'quit', label: 'Quit review', hint: 'stop reviewing' },
|
|
536
679
|
],
|
|
@@ -552,6 +695,26 @@ async function memoryReview(args) {
|
|
|
552
695
|
continue;
|
|
553
696
|
}
|
|
554
697
|
|
|
698
|
+
if (action === 'reclassify') {
|
|
699
|
+
try {
|
|
700
|
+
const newClass = await promptReclassify(mem);
|
|
701
|
+
if (newClass) {
|
|
702
|
+
await callMemoryTool('memory_update', {
|
|
703
|
+
memory_id: mem.id,
|
|
704
|
+
...newClass,
|
|
705
|
+
});
|
|
706
|
+
reclassified++;
|
|
707
|
+
fmt.logSuccess(`Memory ${mem.id.slice(0, 8)} reclassified → ${newClass.layer || '-'}/${(newClass.overlay || []).join(',')}/${(newClass.angle || []).join(',')}`);
|
|
708
|
+
} else {
|
|
709
|
+
skipped++;
|
|
710
|
+
}
|
|
711
|
+
} catch (err) {
|
|
712
|
+
fmt.logError(`Reclassify failed for ${mem.id.slice(0, 8)}: ${err.message}`);
|
|
713
|
+
skipped++;
|
|
714
|
+
}
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
555
718
|
const feedbackType = action === 'confirm' ? 'validate' : 'invalidate';
|
|
556
719
|
|
|
557
720
|
try {
|
|
@@ -575,9 +738,10 @@ async function memoryReview(args) {
|
|
|
575
738
|
}
|
|
576
739
|
|
|
577
740
|
const summaryLines = [
|
|
578
|
-
` ${chalk.green('Validated:')}
|
|
579
|
-
` ${chalk.red('Invalidated:')}
|
|
580
|
-
` ${chalk.
|
|
741
|
+
` ${chalk.green('Validated:')} ${validated}`,
|
|
742
|
+
` ${chalk.red('Invalidated:')} ${invalidated}`,
|
|
743
|
+
` ${chalk.blue('Reclassified:')} ${reclassified}`,
|
|
744
|
+
` ${chalk.dim('Skipped:')} ${skipped}`,
|
|
581
745
|
].join('\n');
|
|
582
746
|
fmt.note(summaryLines, 'Review Summary');
|
|
583
747
|
fmt.outro('Review complete');
|
|
@@ -822,6 +986,218 @@ function buildPromoteContent(pattern, projectName) {
|
|
|
822
986
|
return parts.join(' ');
|
|
823
987
|
}
|
|
824
988
|
|
|
989
|
+
// ── reclassify helper ────────────────────────────────────────────────
|
|
990
|
+
|
|
991
|
+
const VALID_LAYERS = ['org', 'dept', 'team', 'individual'];
|
|
992
|
+
const VALID_OVERLAYS = ['product', 'feature', 'incident', 'journey', 'surface', 'service', 'general'];
|
|
993
|
+
const VALID_ANGLES = ['business', 'market', 'product', 'ux', 'technical', 'operational', 'delivery'];
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Prompt the user to reclassify a memory's 3D coordinates.
|
|
997
|
+
* Returns { layer, overlay, angle } or null if cancelled.
|
|
998
|
+
*/
|
|
999
|
+
async function promptReclassify(mem) {
|
|
1000
|
+
const newLayer = await fmt.select({
|
|
1001
|
+
message: `Layer (current: ${mem.layer || 'unclassified'})`,
|
|
1002
|
+
options: VALID_LAYERS.map(l => ({
|
|
1003
|
+
value: l,
|
|
1004
|
+
label: l,
|
|
1005
|
+
hint: l === mem.layer ? 'current' : undefined,
|
|
1006
|
+
})),
|
|
1007
|
+
initialValue: mem.layer || 'team',
|
|
1008
|
+
});
|
|
1009
|
+
if (fmt.isCancel(newLayer)) return null;
|
|
1010
|
+
|
|
1011
|
+
let overlayInput;
|
|
1012
|
+
try {
|
|
1013
|
+
overlayInput = await text({
|
|
1014
|
+
message: `Overlays (current: ${Array.isArray(mem.overlay) ? mem.overlay.join(',') : '-'})`,
|
|
1015
|
+
placeholder: 'comma-separated: product,feature,incident,journey,surface,service,general',
|
|
1016
|
+
initialValue: Array.isArray(mem.overlay) ? mem.overlay.join(',') : 'general',
|
|
1017
|
+
validate: (v) => {
|
|
1018
|
+
const parts = v.split(',').map(s => s.trim()).filter(Boolean);
|
|
1019
|
+
if (parts.length === 0) return 'At least one overlay required';
|
|
1020
|
+
if (parts.length > 3) return 'Max 3 overlays';
|
|
1021
|
+
const invalid = parts.filter(p => !VALID_OVERLAYS.includes(p));
|
|
1022
|
+
if (invalid.length) return `Invalid: ${invalid.join(', ')}. Valid: ${VALID_OVERLAYS.join(', ')}`;
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
} catch {
|
|
1026
|
+
overlayInput = Array.isArray(mem.overlay) ? mem.overlay.join(',') : 'general';
|
|
1027
|
+
}
|
|
1028
|
+
if (isCancel(overlayInput)) return null;
|
|
1029
|
+
|
|
1030
|
+
let angleInput;
|
|
1031
|
+
try {
|
|
1032
|
+
angleInput = await text({
|
|
1033
|
+
message: `Angles (current: ${Array.isArray(mem.angle) ? mem.angle.join(',') : '-'})`,
|
|
1034
|
+
placeholder: 'comma-separated: business,market,product,ux,technical,operational,delivery',
|
|
1035
|
+
initialValue: Array.isArray(mem.angle) ? mem.angle.join(',') : 'technical',
|
|
1036
|
+
validate: (v) => {
|
|
1037
|
+
const parts = v.split(',').map(s => s.trim()).filter(Boolean);
|
|
1038
|
+
if (parts.length === 0) return 'At least one angle required';
|
|
1039
|
+
if (parts.length > 2) return 'Max 2 angles';
|
|
1040
|
+
const invalid = parts.filter(p => !VALID_ANGLES.includes(p));
|
|
1041
|
+
if (invalid.length) return `Invalid: ${invalid.join(', ')}. Valid: ${VALID_ANGLES.join(', ')}`;
|
|
1042
|
+
},
|
|
1043
|
+
});
|
|
1044
|
+
} catch {
|
|
1045
|
+
angleInput = Array.isArray(mem.angle) ? mem.angle.join(',') : 'technical';
|
|
1046
|
+
}
|
|
1047
|
+
if (isCancel(angleInput)) return null;
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
layer: newLayer,
|
|
1051
|
+
overlay: overlayInput.split(',').map(s => s.trim()).filter(Boolean),
|
|
1052
|
+
angle: angleInput.split(',').map(s => s.trim()).filter(Boolean),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// ── scope-promote ────────────────────────────────────────────────────
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Auto-promote memories observed in 2+ repos to namespace scope.
|
|
1060
|
+
* PRD §4.3.1: personal → repo → namespace → global promotion flow.
|
|
1061
|
+
*/
|
|
1062
|
+
async function memoryScopePromote(args) {
|
|
1063
|
+
fmt.intro('aw memory scope-promote');
|
|
1064
|
+
|
|
1065
|
+
const dryRun = args['--dry-run'] === true;
|
|
1066
|
+
const minRepos = parseInt(args['--min-repos'] || '2', 10);
|
|
1067
|
+
|
|
1068
|
+
const s = fmt.spinner();
|
|
1069
|
+
s.start('Fetching repo-scoped memories...');
|
|
1070
|
+
|
|
1071
|
+
try {
|
|
1072
|
+
// Get all active memories with repo_slug set (repo-scoped)
|
|
1073
|
+
const result = await callMemoryTool('memory_search', {
|
|
1074
|
+
query: '*',
|
|
1075
|
+
limit: 500,
|
|
1076
|
+
});
|
|
1077
|
+
const memories = Array.isArray(result) ? result : (result?.memories ?? result?.results ?? []);
|
|
1078
|
+
|
|
1079
|
+
if (memories.length === 0) {
|
|
1080
|
+
s.stop('No memories found');
|
|
1081
|
+
fmt.outro('Nothing to promote');
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Group by content similarity key (first 100 chars normalized)
|
|
1086
|
+
const contentGroups = new Map(); // normalizedContent → [{ mem, repo_slug }]
|
|
1087
|
+
|
|
1088
|
+
for (const mem of memories) {
|
|
1089
|
+
if (!mem.repo_slug) continue; // Skip non-repo-scoped memories
|
|
1090
|
+
if (mem.scope_level === 'global' || mem.scope_level === 'namespace') continue; // Already promoted
|
|
1091
|
+
|
|
1092
|
+
const key = normalizeForGrouping(mem.content || mem.text || '');
|
|
1093
|
+
if (!key) continue;
|
|
1094
|
+
|
|
1095
|
+
if (!contentGroups.has(key)) contentGroups.set(key, []);
|
|
1096
|
+
contentGroups.get(key).push({ mem, repo_slug: mem.repo_slug });
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Find memories that appear in minRepos+ different repos
|
|
1100
|
+
const promotable = [];
|
|
1101
|
+
for (const [key, entries] of contentGroups) {
|
|
1102
|
+
const uniqueRepos = new Set(entries.map(e => e.repo_slug));
|
|
1103
|
+
if (uniqueRepos.size >= minRepos) {
|
|
1104
|
+
// Pick the highest-confidence version as the canonical one
|
|
1105
|
+
const sorted = entries.sort((a, b) => (b.mem.confidence ?? 0) - (a.mem.confidence ?? 0));
|
|
1106
|
+
promotable.push({
|
|
1107
|
+
canonical: sorted[0].mem,
|
|
1108
|
+
repos: [...uniqueRepos],
|
|
1109
|
+
count: entries.length,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
s.stop(`Found ${promotable.length} memor${promotable.length === 1 ? 'y' : 'ies'} in ${minRepos}+ repos`);
|
|
1115
|
+
|
|
1116
|
+
if (promotable.length === 0) {
|
|
1117
|
+
fmt.logInfo(`No memories found in ${minRepos}+ different repos.`);
|
|
1118
|
+
fmt.outro('Nothing to promote');
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Display candidates
|
|
1123
|
+
for (const p of promotable) {
|
|
1124
|
+
const content = (p.canonical.content || '').slice(0, 100);
|
|
1125
|
+
fmt.note(
|
|
1126
|
+
`${chalk.dim('content:')} ${content}\n` +
|
|
1127
|
+
`${chalk.dim('repos:')} ${p.repos.join(', ')}\n` +
|
|
1128
|
+
`${chalk.dim('conf:')} ${p.canonical.confidence ?? '-'}`,
|
|
1129
|
+
`${p.repos.length} repos, ${p.count} occurrences`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (dryRun) {
|
|
1134
|
+
fmt.logInfo('Dry run — no changes made.');
|
|
1135
|
+
fmt.outro(`${promotable.length} memories would be promoted to namespace scope`);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Confirm
|
|
1140
|
+
if (!args['--yes'] && !args['-y']) {
|
|
1141
|
+
try {
|
|
1142
|
+
const shouldPromote = await confirm({
|
|
1143
|
+
message: `Promote ${promotable.length} memor${promotable.length !== 1 ? 'ies' : 'y'} to namespace scope?`,
|
|
1144
|
+
});
|
|
1145
|
+
if (isCancel(shouldPromote) || !shouldPromote) {
|
|
1146
|
+
fmt.outro('Promotion cancelled');
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
} catch {
|
|
1150
|
+
// Non-TTY — proceed
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Promote each by updating scope_level to 'namespace' and clearing repo_slug
|
|
1155
|
+
const ps = fmt.spinner();
|
|
1156
|
+
ps.start('Promoting to namespace scope...');
|
|
1157
|
+
|
|
1158
|
+
let promoted = 0;
|
|
1159
|
+
let failed = 0;
|
|
1160
|
+
|
|
1161
|
+
for (const p of promotable) {
|
|
1162
|
+
try {
|
|
1163
|
+
await callMemoryTool('memory_update', {
|
|
1164
|
+
memory_id: p.canonical.id,
|
|
1165
|
+
scope_level: 'namespace',
|
|
1166
|
+
repo_slug: null,
|
|
1167
|
+
});
|
|
1168
|
+
promoted++;
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
failed++;
|
|
1171
|
+
fmt.logError(`Failed to promote ${(p.canonical.id || '').slice(0, 8)}: ${err.message}`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
ps.stop('Promotion complete');
|
|
1176
|
+
|
|
1177
|
+
const resultLines = [
|
|
1178
|
+
` ${chalk.green('Promoted:')} ${promoted}`,
|
|
1179
|
+
failed > 0 ? ` ${chalk.red('Failed:')} ${failed}` : null,
|
|
1180
|
+
].filter(Boolean).join('\n');
|
|
1181
|
+
fmt.note(resultLines, 'Scope Promote Summary');
|
|
1182
|
+
fmt.outro(`${promoted} memor${promoted !== 1 ? 'ies' : 'y'} promoted to namespace scope`);
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
s.stop(chalk.red('Failed'));
|
|
1185
|
+
fmt.cancel(`Scope promote failed: ${err.message}`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Normalize memory content for grouping — lowercase, strip whitespace, truncate.
|
|
1191
|
+
*/
|
|
1192
|
+
function normalizeForGrouping(content) {
|
|
1193
|
+
if (!content) return '';
|
|
1194
|
+
return content
|
|
1195
|
+
.toLowerCase()
|
|
1196
|
+
.replace(/\s+/g, ' ')
|
|
1197
|
+
.trim()
|
|
1198
|
+
.slice(0, 100);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
825
1201
|
// ── help ─────────────────────────────────────────────────────────────
|
|
826
1202
|
|
|
827
1203
|
function memoryHelp() {
|
|
@@ -877,6 +1253,10 @@ function memoryHelp() {
|
|
|
877
1253
|
cmd(' --limit <n>', 'Observations to scan (default: 200)'),
|
|
878
1254
|
cmd(' --min <n>', 'Min occurrences to promote (default: 3)'),
|
|
879
1255
|
'',
|
|
1256
|
+
cmd('aw memory scope-promote', 'Promote repo→namespace scope'),
|
|
1257
|
+
cmd(' --min-repos <n>', 'Min repos to trigger (default: 2)'),
|
|
1258
|
+
cmd(' --dry-run', 'Preview without changes'),
|
|
1259
|
+
'',
|
|
880
1260
|
].join('\n');
|
|
881
1261
|
|
|
882
1262
|
console.log(help);
|
package/memory-bridge.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { MCP_BASE_URL, AW_HOME, REGISTRY_DIR } from './constants.mjs';
|
|
6
6
|
import * as config from './config.mjs';
|
|
7
|
+
import { enqueue, flushQueue } from './memory-queue.mjs';
|
|
7
8
|
|
|
8
9
|
let _rpcId = 1;
|
|
9
10
|
|
|
@@ -55,13 +56,86 @@ function resolveGhToken() {
|
|
|
55
56
|
return null;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/** Write operations that can be queued when MCP is unreachable. */
|
|
60
|
+
const WRITE_OPS = new Set([
|
|
61
|
+
'memory_store',
|
|
62
|
+
'memory_curated_store',
|
|
63
|
+
'memory_feedback',
|
|
64
|
+
'memory_update',
|
|
65
|
+
'memory_invalidate',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** Detect network-level errors (MCP unreachable). */
|
|
69
|
+
function isNetworkError(err) {
|
|
70
|
+
if (!err) return false;
|
|
71
|
+
const msg = err.message || '';
|
|
72
|
+
const code = err.code || '';
|
|
73
|
+
return (
|
|
74
|
+
code === 'ECONNREFUSED' ||
|
|
75
|
+
code === 'ECONNRESET' ||
|
|
76
|
+
code === 'ENOTFOUND' ||
|
|
77
|
+
code === 'UND_ERR_CONNECT_TIMEOUT' ||
|
|
78
|
+
msg.includes('fetch failed') ||
|
|
79
|
+
msg.includes('network') ||
|
|
80
|
+
msg.includes('ECONNREFUSED') ||
|
|
81
|
+
msg.includes('ECONNRESET') ||
|
|
82
|
+
msg.includes('ENOTFOUND') ||
|
|
83
|
+
err.name === 'AbortError' ||
|
|
84
|
+
err.name === 'TimeoutError'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Flag to prevent recursive flush attempts. */
|
|
89
|
+
let _flushing = false;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Try to flush queued operations if MCP is reachable.
|
|
93
|
+
* Called at the start of every successful callMemoryTool invocation.
|
|
94
|
+
*/
|
|
95
|
+
async function tryFlushQueue() {
|
|
96
|
+
if (_flushing) return;
|
|
97
|
+
_flushing = true;
|
|
98
|
+
try {
|
|
99
|
+
await flushQueue(_callMemoryToolRaw);
|
|
100
|
+
} catch {
|
|
101
|
+
// Flush is best-effort — don't block the current operation
|
|
102
|
+
} finally {
|
|
103
|
+
_flushing = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
58
107
|
/**
|
|
59
108
|
* Call an MCP memory tool by name via JSON-RPC 2.0 (Streamable HTTP).
|
|
109
|
+
* Wraps the raw transport with offline queue support:
|
|
110
|
+
* - WRITE ops are enqueued when MCP is unreachable
|
|
111
|
+
* - READ ops propagate the error
|
|
112
|
+
* - On success, flushes any queued operations
|
|
60
113
|
* @param {string} toolName — MCP tool name (e.g. 'memory_curated_store', 'memory_search')
|
|
61
114
|
* @param {object} params — Tool arguments
|
|
62
|
-
* @returns {Promise<object>} Parsed tool result
|
|
115
|
+
* @returns {Promise<object>} Parsed tool result (or synthetic {queued:true})
|
|
63
116
|
*/
|
|
64
117
|
export async function callMemoryTool(toolName, params) {
|
|
118
|
+
try {
|
|
119
|
+
const result = await _callMemoryToolRaw(toolName, params);
|
|
120
|
+
// MCP is reachable — flush any queued items in the background
|
|
121
|
+
tryFlushQueue();
|
|
122
|
+
return result;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (isNetworkError(err) && WRITE_OPS.has(toolName)) {
|
|
125
|
+
enqueue(toolName, params);
|
|
126
|
+
return { queued: true, message: 'MCP unreachable — operation queued for retry' };
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Raw MCP transport — JSON-RPC 2.0 over Streamable HTTP.
|
|
134
|
+
* @param {string} toolName
|
|
135
|
+
* @param {object} params
|
|
136
|
+
* @returns {Promise<object>}
|
|
137
|
+
*/
|
|
138
|
+
async function _callMemoryToolRaw(toolName, params) {
|
|
65
139
|
const response = await fetch(MCP_BASE_URL, {
|
|
66
140
|
method: 'POST',
|
|
67
141
|
headers: resolveHeaders(),
|
package/memory-queue.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// memory-queue.mjs — Offline queue for memory operations when MCP is unreachable
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { AW_HOME } from './constants.mjs';
|
|
6
|
+
|
|
7
|
+
/** Queue file path: ~/.aw/memory-queue.jsonl */
|
|
8
|
+
const QUEUE_PATH = join(AW_HOME, 'memory-queue.jsonl');
|
|
9
|
+
|
|
10
|
+
/** Max age for queued items before they are discarded (7 days in ms) */
|
|
11
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Append a write operation to the offline queue.
|
|
15
|
+
* @param {string} op — MCP tool name (e.g. 'memory_store', 'memory_curated_store')
|
|
16
|
+
* @param {object} payload — Tool arguments
|
|
17
|
+
*/
|
|
18
|
+
export function enqueue(op, payload) {
|
|
19
|
+
const dir = dirname(QUEUE_PATH);
|
|
20
|
+
if (!existsSync(dir)) {
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const entry = {
|
|
25
|
+
op,
|
|
26
|
+
payload,
|
|
27
|
+
queued_at: new Date().toISOString(),
|
|
28
|
+
attempt: 0,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
appendFileSync(QUEUE_PATH, JSON.stringify(entry) + '\n', 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Flush the offline queue — process each item via the provided sender function.
|
|
36
|
+
* Removes successfully processed items, retries failed ones (increments attempt),
|
|
37
|
+
* and discards items older than 7 days.
|
|
38
|
+
* @param {(toolName: string, params: object) => Promise<object>} sender — raw MCP call function
|
|
39
|
+
* @returns {Promise<{processed: number, failed: number, discarded: number}>}
|
|
40
|
+
*/
|
|
41
|
+
export async function flushQueue(sender) {
|
|
42
|
+
if (!existsSync(QUEUE_PATH)) {
|
|
43
|
+
return { processed: 0, failed: 0, discarded: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const raw = readFileSync(QUEUE_PATH, 'utf8').trim();
|
|
47
|
+
if (!raw) {
|
|
48
|
+
return { processed: 0, failed: 0, discarded: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
52
|
+
const remaining = [];
|
|
53
|
+
let processed = 0;
|
|
54
|
+
let failed = 0;
|
|
55
|
+
let discarded = 0;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
let entry;
|
|
60
|
+
try {
|
|
61
|
+
entry = JSON.parse(line);
|
|
62
|
+
} catch {
|
|
63
|
+
// Malformed line — discard
|
|
64
|
+
discarded++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Discard items older than 7 days
|
|
69
|
+
const age = now - new Date(entry.queued_at).getTime();
|
|
70
|
+
if (age > MAX_AGE_MS) {
|
|
71
|
+
discarded++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await sender(entry.op, entry.payload);
|
|
77
|
+
processed++;
|
|
78
|
+
} catch {
|
|
79
|
+
entry.attempt = (entry.attempt || 0) + 1;
|
|
80
|
+
remaining.push(entry);
|
|
81
|
+
failed++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Rewrite queue with only the remaining (failed) items
|
|
86
|
+
if (remaining.length > 0) {
|
|
87
|
+
const data = remaining.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
88
|
+
writeFileSync(QUEUE_PATH, data, 'utf8');
|
|
89
|
+
} else {
|
|
90
|
+
writeFileSync(QUEUE_PATH, '', 'utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { processed, failed, discarded };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get queue statistics for display.
|
|
98
|
+
* @returns {{pending: number, oldest: string|null, newest: string|null}}
|
|
99
|
+
*/
|
|
100
|
+
export function getQueueStats() {
|
|
101
|
+
if (!existsSync(QUEUE_PATH)) {
|
|
102
|
+
return { pending: 0, oldest: null, newest: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const raw = readFileSync(QUEUE_PATH, 'utf8').trim();
|
|
106
|
+
if (!raw) {
|
|
107
|
+
return { pending: 0, oldest: null, newest: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
111
|
+
let oldest = null;
|
|
112
|
+
let newest = null;
|
|
113
|
+
let pending = 0;
|
|
114
|
+
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
try {
|
|
117
|
+
const entry = JSON.parse(line);
|
|
118
|
+
pending++;
|
|
119
|
+
const ts = entry.queued_at;
|
|
120
|
+
if (!oldest || ts < oldest) oldest = ts;
|
|
121
|
+
if (!newest || ts > newest) newest = ts;
|
|
122
|
+
} catch {
|
|
123
|
+
// skip malformed
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { pending, oldest, newest };
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghl-ai/aw",
|
|
3
|
-
"version": "0.1.37-beta.
|
|
3
|
+
"version": "0.1.37-beta.44",
|
|
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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"render-rules.mjs",
|
|
29
29
|
"telemetry.mjs",
|
|
30
30
|
"memory-bridge.mjs",
|
|
31
|
-
"memory-sync.mjs"
|
|
31
|
+
"memory-sync.mjs",
|
|
32
|
+
"memory-queue.mjs"
|
|
32
33
|
],
|
|
33
34
|
"engines": {
|
|
34
35
|
"node": ">=18.0.0"
|