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

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/init.mjs CHANGED
@@ -353,7 +353,6 @@ export async function initCommand(args) {
353
353
  try {
354
354
  await initPersistentClone(repoUrl, AW_HOME, sparsePaths);
355
355
  ensureAwGitignore(AW_HOME);
356
- mkdirSync(join(GLOBAL_AW_DIR, 'memory'), { recursive: true });
357
356
  s.stop('Registry cloned');
358
357
  } catch (e) {
359
358
  s.stop(chalk.red('Clone failed'));
@@ -382,6 +381,9 @@ export async function initCommand(args) {
382
381
  }
383
382
  }
384
383
 
384
+ // Create memory dir after symlink so GLOBAL_AW_DIR resolves correctly
385
+ mkdirSync(join(GLOBAL_AW_DIR, 'memory'), { recursive: true });
386
+
385
387
  // Create sync config — default to 'platform' when no namespace specified
386
388
  const cfg = config.create(GLOBAL_AW_DIR, { namespace: team || 'platform', user });
387
389
  if (folderName) {
@@ -1,16 +1,154 @@
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
- import { callMemoryTool } from '../memory-bridge.mjs';
9
+ import { callMemoryTool, computeAncestry, resolveStoreNamespace, resolveReadPaths } from '../memory-bridge.mjs';
10
10
  import { syncMemories } from '../memory-sync.mjs';
11
- import { REGISTRY_DIR } from '../constants.mjs';
11
+ import { REGISTRY_DIR, ORG_SCOPE_NAMESPACE } from '../constants.mjs';
12
+ import { getQueueStats } from '../memory-queue.mjs';
13
+ import * as config from '../config.mjs';
14
+
15
+ /** Load .sync-config.json from the registry directory. */
16
+ function loadCfg() {
17
+ const registryDir = join(homedir(), '.aw', REGISTRY_DIR);
18
+ return config.load(registryDir) || {};
19
+ }
20
+
21
+ /**
22
+ * Validate that an explicit namespace is within the user's ancestry chain.
23
+ * Returns true if valid, false if not.
24
+ */
25
+ function validateNamespaceAccess(cfg, explicitNs) {
26
+ const includes = cfg.include || [];
27
+ // Build full ancestry from the user's include paths
28
+ const validPaths = includes.length > 0
29
+ ? computeAncestry(includes)
30
+ : [cfg.namespace || ORG_SCOPE_NAMESPACE];
31
+ // Always allow the org root
32
+ if (!validPaths.includes(ORG_SCOPE_NAMESPACE)) validPaths.push(ORG_SCOPE_NAMESPACE);
33
+ return validPaths.includes(explicitNs);
34
+ }
35
+
36
+ // ── legacy import ─────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * One-time import of learned skills from ~/.claude/skills/learned/*.md
40
+ * into the memory system. Runs silently if nothing to import.
41
+ */
42
+ async function checkAndRunLegacyImport() {
43
+ const awDir = join(homedir(), '.aw');
44
+ const flagFile = join(awDir, 'memory-migrated');
45
+
46
+ // Already migrated — bail immediately
47
+ if (existsSync(flagFile)) return;
48
+
49
+ const skillsDir = join(homedir(), '.claude', 'skills', 'learned');
50
+
51
+ // No skills directory — mark as migrated and bail
52
+ if (!existsSync(skillsDir)) {
53
+ mkdirSync(awDir, { recursive: true });
54
+ writeFileSync(flagFile, JSON.stringify({ migrated_at: new Date().toISOString(), count: 0, skipped: 0 }, null, 2));
55
+ return;
56
+ }
57
+
58
+ let files;
59
+ try {
60
+ files = readdirSync(skillsDir).filter(f => f.endsWith('.md'));
61
+ } catch {
62
+ return; // Can't read directory — skip silently
63
+ }
64
+
65
+ // No .md files found — mark as migrated and bail
66
+ if (files.length === 0) {
67
+ mkdirSync(awDir, { recursive: true });
68
+ writeFileSync(flagFile, JSON.stringify({ migrated_at: new Date().toISOString(), count: 0, skipped: 0 }, null, 2));
69
+ return;
70
+ }
71
+
72
+ const s = fmt.spinner();
73
+ s.start(`Importing learned skills... 0/${files.length}`);
74
+
75
+ let imported = 0;
76
+ let skipped = 0;
77
+
78
+ for (let i = 0; i < files.length; i++) {
79
+ const file = files[i];
80
+ try {
81
+ const raw = readFileSync(join(skillsDir, file), 'utf8');
82
+
83
+ // Parse YAML frontmatter
84
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
85
+ let description = '';
86
+ let body = '';
87
+
88
+ if (fmMatch) {
89
+ const frontmatter = fmMatch[1];
90
+ body = fmMatch[2].trim();
91
+
92
+ // Extract name and description from frontmatter
93
+ for (const line of frontmatter.split('\n')) {
94
+ const kvMatch = line.match(/^(\w+):\s*(.+)$/);
95
+ if (kvMatch) {
96
+ const [, key, value] = kvMatch;
97
+ if (key === 'description') {
98
+ description = value.replace(/^["']|["']$/g, '').trim();
99
+ } else if (key === 'name' && !description) {
100
+ description = value.replace(/^["']|["']$/g, '').trim();
101
+ }
102
+ }
103
+ }
104
+ } else {
105
+ // No frontmatter — use entire content as body
106
+ body = raw.trim();
107
+ }
108
+
109
+ // Build content: description + body, truncated to 1000 chars
110
+ const content = ((description ? description + '\n\n' : '') + body).slice(0, 1000);
111
+
112
+ if (!content) {
113
+ skipped++;
114
+ continue;
115
+ }
116
+
117
+ await callMemoryTool('memory_store', {
118
+ content,
119
+ type: 'learning',
120
+ source: 'legacy-import',
121
+ curate: true,
122
+ confidence: 0.6,
123
+ });
124
+
125
+ imported++;
126
+ } catch (err) {
127
+ skipped++;
128
+ fmt.logWarn(`Skipped ${file}: ${err.message}`);
129
+ }
130
+
131
+ s.message = `Importing learned skills... ${i + 1}/${files.length}`;
132
+ }
133
+
134
+ s.stop(`Imported ${imported} learned skill${imported !== 1 ? 's' : ''}`);
135
+
136
+ if (imported > 0 || skipped > 0) {
137
+ fmt.logStep(`Imported ${imported} learned skills as memories${skipped > 0 ? ` (${skipped} skipped)` : ''}`);
138
+ }
139
+
140
+ // Write flag file
141
+ mkdirSync(awDir, { recursive: true });
142
+ writeFileSync(flagFile, JSON.stringify({
143
+ migrated_at: new Date().toISOString(),
144
+ count: imported,
145
+ skipped,
146
+ }, null, 2));
147
+ }
12
148
 
13
149
  export async function memoryCommand(args) {
150
+ await checkAndRunLegacyImport();
151
+
14
152
  const sub = args._positional?.[0];
15
153
  switch (sub) {
16
154
  case 'store': return memoryStore(args);
@@ -21,9 +159,10 @@ export async function memoryCommand(args) {
21
159
  case 'invalidate': return memoryInvalidate(args);
22
160
  case 'sync': return memorySync(args);
23
161
  case 'audit': return memoryAudit(args);
24
- case 'review': return memoryReview(args);
25
- case 'promote': return memoryPromote(args);
26
- default: return memoryHelp();
162
+ case 'review': return memoryReview(args);
163
+ case 'promote': return memoryPromote(args);
164
+ case 'scope-promote': return memoryScopePromote(args);
165
+ default: return memoryHelp();
27
166
  }
28
167
  }
29
168
 
@@ -38,18 +177,34 @@ async function memoryStore(args) {
38
177
 
39
178
  fmt.intro('aw memory store');
40
179
 
180
+ const cfg = loadCfg();
181
+ const explicitNs = args['--namespace'] || null;
182
+
183
+ // Validate explicit namespace is within ancestry chain
184
+ if (explicitNs && !validateNamespaceAccess(cfg, explicitNs)) {
185
+ fmt.cancel(`Namespace "${explicitNs}" is not in your ancestry chain. Your include paths: ${(cfg.include || []).join(', ') || '(none)'}`);
186
+ return;
187
+ }
188
+
189
+ // Resolve store namespace via layered defaults
190
+ const storeNs = resolveStoreNamespace(cfg, explicitNs);
191
+
41
192
  const params = { content };
42
193
  if (args['--type']) params.type = args['--type'];
43
- if (args['--namespace']) params.namespace = args['--namespace'];
194
+ if (storeNs) params.namespace = storeNs;
44
195
  if (args['--layer']) params.layer = args['--layer'];
45
196
  if (args['--overlay']) params.overlay = args['--overlay'].split(',').map(s => s.trim());
46
197
  if (args['--angle']) params.angle = args['--angle'].split(',').map(s => s.trim());
47
198
  if (args['--tags']) params.tags = args['--tags'].split(',').map(s => s.trim());
48
199
 
200
+ // Build call opts
201
+ const callOpts = {};
202
+ if (storeNs) callOpts.storeNamespace = storeNs;
203
+
49
204
  const s = fmt.spinner();
50
205
  s.start('Storing memory...');
51
206
  try {
52
- const result = await callMemoryTool('memory_curated_store', params);
207
+ const result = await callMemoryTool('memory_curated_store', params, callOpts);
53
208
  s.stop('Memory stored');
54
209
 
55
210
  const data = result?.result ?? result;
@@ -86,17 +241,29 @@ async function memorySearch(args) {
86
241
 
87
242
  fmt.intro('aw memory search');
88
243
 
244
+ const cfg = loadCfg();
245
+ const explicitNs = args['--namespace'] || null;
246
+
247
+ // Resolve read paths via layered defaults
248
+ const readPaths = resolveReadPaths(cfg, explicitNs);
249
+
89
250
  const params = { query };
90
- if (args['--namespace']) params.namespace = args['--namespace'];
251
+ if (explicitNs) params.namespace = explicitNs;
91
252
  if (args['--limit']) params.limit = parseInt(args['--limit'], 10);
92
253
  if (args['--layer']) params.layer = args['--layer'];
93
254
  if (args['--overlay']) params.overlay = args['--overlay'];
94
255
  if (args['--angle']) params.angle = args['--angle'];
95
256
 
257
+ // Override namespace paths header when explicit ns is provided
258
+ const callOpts = {};
259
+ if (explicitNs) {
260
+ callOpts.namespacePaths = readPaths.join(',');
261
+ }
262
+
96
263
  const s = fmt.spinner();
97
264
  s.start('Searching memories...');
98
265
  try {
99
- const result = await callMemoryTool('memory_search', params);
266
+ const result = await callMemoryTool('memory_search', params, callOpts);
100
267
  s.stop('Search complete');
101
268
 
102
269
  const memories = Array.isArray(result) ? result : (result?.memories ?? result?.results ?? []);
@@ -142,18 +309,30 @@ async function memoryPack(args) {
142
309
 
143
310
  fmt.intro('aw memory pack');
144
311
 
312
+ const cfg = loadCfg();
313
+ const explicitNs = args['--namespace'] || null;
314
+
315
+ // Resolve read paths via layered defaults (same as search)
316
+ const readPaths = resolveReadPaths(cfg, explicitNs);
317
+
145
318
  const params = { query };
146
- if (args['--namespace']) params.namespace = args['--namespace'];
319
+ if (explicitNs) params.namespace = explicitNs;
147
320
  if (args['--budget']) params.token_budget = parseInt(args['--budget'], 10);
148
321
  else params.token_budget = 3500;
149
322
  if (args['--layer']) params.layer = args['--layer'];
150
323
  if (args['--overlay']) params.overlay = args['--overlay'];
151
324
  if (args['--angle']) params.angle = args['--angle'];
152
325
 
326
+ // Override namespace paths header when explicit ns is provided
327
+ const callOpts = {};
328
+ if (explicitNs) {
329
+ callOpts.namespacePaths = readPaths.join(',');
330
+ }
331
+
153
332
  const s = fmt.spinner();
154
333
  s.start('Building memory pack...');
155
334
  try {
156
- const result = await callMemoryTool('memory_pack', params);
335
+ const result = await callMemoryTool('memory_pack', params, callOpts);
157
336
  s.stop('Pack ready');
158
337
 
159
338
  const pack = result?.pack ?? result?.content ?? result;
@@ -219,6 +398,17 @@ async function memoryStats(args) {
219
398
  }
220
399
 
221
400
  fmt.logStep(`Total memories: ${chalk.bold(stats.total ?? 0)}`);
401
+
402
+ // Show offline queue stats if there are pending items
403
+ const queueStats = getQueueStats();
404
+ if (queueStats.pending > 0) {
405
+ const queueLines = [
406
+ ` ${chalk.yellow('Pending:'.padEnd(20))} ${queueStats.pending}`,
407
+ queueStats.oldest ? ` ${chalk.dim('Oldest:'.padEnd(20))} ${formatTimeAgo(queueStats.oldest)}` : null,
408
+ ].filter(Boolean).join('\n');
409
+ fmt.note(queueLines, 'Offline Queue');
410
+ }
411
+
222
412
  fmt.outro('Stats complete');
223
413
  } catch (err) {
224
414
  s.stop(chalk.red('Failed'));
@@ -226,6 +416,19 @@ async function memoryStats(args) {
226
416
  }
227
417
  }
228
418
 
419
+ /** Format an ISO timestamp as a human-readable "time ago" string. */
420
+ function formatTimeAgo(isoString) {
421
+ const diff = Date.now() - new Date(isoString).getTime();
422
+ const seconds = Math.floor(diff / 1000);
423
+ if (seconds < 60) return `${seconds}s ago`;
424
+ const minutes = Math.floor(seconds / 60);
425
+ if (minutes < 60) return `${minutes}m ago`;
426
+ const hours = Math.floor(minutes / 60);
427
+ if (hours < 24) return `${hours}h ago`;
428
+ const days = Math.floor(hours / 24);
429
+ return `${days}d ago`;
430
+ }
431
+
229
432
  // ── validate ──────────────────────────────────────────────────────────
230
433
 
231
434
  async function memoryValidate(args) {
@@ -505,6 +708,7 @@ async function memoryReview(args) {
505
708
 
506
709
  let validated = 0;
507
710
  let invalidated = 0;
711
+ let reclassified = 0;
508
712
  let skipped = 0;
509
713
 
510
714
  for (let i = 0; i < needsReview.length; i++) {
@@ -531,6 +735,7 @@ async function memoryReview(args) {
531
735
  options: [
532
736
  { value: 'confirm', label: 'Confirm', hint: 'validate this memory' },
533
737
  { value: 'invalidate', label: 'Invalidate', hint: 'mark as invalid' },
738
+ { value: 'reclassify', label: 'Reclassify', hint: 'change layer/overlay/angle' },
534
739
  { value: 'skip', label: 'Skip', hint: 'move to next' },
535
740
  { value: 'quit', label: 'Quit review', hint: 'stop reviewing' },
536
741
  ],
@@ -552,6 +757,26 @@ async function memoryReview(args) {
552
757
  continue;
553
758
  }
554
759
 
760
+ if (action === 'reclassify') {
761
+ try {
762
+ const newClass = await promptReclassify(mem);
763
+ if (newClass) {
764
+ await callMemoryTool('memory_update', {
765
+ memory_id: mem.id,
766
+ ...newClass,
767
+ });
768
+ reclassified++;
769
+ fmt.logSuccess(`Memory ${mem.id.slice(0, 8)} reclassified → ${newClass.layer || '-'}/${(newClass.overlay || []).join(',')}/${(newClass.angle || []).join(',')}`);
770
+ } else {
771
+ skipped++;
772
+ }
773
+ } catch (err) {
774
+ fmt.logError(`Reclassify failed for ${mem.id.slice(0, 8)}: ${err.message}`);
775
+ skipped++;
776
+ }
777
+ continue;
778
+ }
779
+
555
780
  const feedbackType = action === 'confirm' ? 'validate' : 'invalidate';
556
781
 
557
782
  try {
@@ -575,9 +800,10 @@ async function memoryReview(args) {
575
800
  }
576
801
 
577
802
  const summaryLines = [
578
- ` ${chalk.green('Validated:')} ${validated}`,
579
- ` ${chalk.red('Invalidated:')} ${invalidated}`,
580
- ` ${chalk.dim('Skipped:')} ${skipped}`,
803
+ ` ${chalk.green('Validated:')} ${validated}`,
804
+ ` ${chalk.red('Invalidated:')} ${invalidated}`,
805
+ ` ${chalk.blue('Reclassified:')} ${reclassified}`,
806
+ ` ${chalk.dim('Skipped:')} ${skipped}`,
581
807
  ].join('\n');
582
808
  fmt.note(summaryLines, 'Review Summary');
583
809
  fmt.outro('Review complete');
@@ -822,6 +1048,218 @@ function buildPromoteContent(pattern, projectName) {
822
1048
  return parts.join(' ');
823
1049
  }
824
1050
 
1051
+ // ── reclassify helper ────────────────────────────────────────────────
1052
+
1053
+ const VALID_LAYERS = ['org', 'dept', 'team', 'individual'];
1054
+ const VALID_OVERLAYS = ['product', 'feature', 'incident', 'journey', 'surface', 'service', 'general'];
1055
+ const VALID_ANGLES = ['business', 'market', 'product', 'ux', 'technical', 'operational', 'delivery'];
1056
+
1057
+ /**
1058
+ * Prompt the user to reclassify a memory's 3D coordinates.
1059
+ * Returns { layer, overlay, angle } or null if cancelled.
1060
+ */
1061
+ async function promptReclassify(mem) {
1062
+ const newLayer = await fmt.select({
1063
+ message: `Layer (current: ${mem.layer || 'unclassified'})`,
1064
+ options: VALID_LAYERS.map(l => ({
1065
+ value: l,
1066
+ label: l,
1067
+ hint: l === mem.layer ? 'current' : undefined,
1068
+ })),
1069
+ initialValue: mem.layer || 'team',
1070
+ });
1071
+ if (fmt.isCancel(newLayer)) return null;
1072
+
1073
+ let overlayInput;
1074
+ try {
1075
+ overlayInput = await text({
1076
+ message: `Overlays (current: ${Array.isArray(mem.overlay) ? mem.overlay.join(',') : '-'})`,
1077
+ placeholder: 'comma-separated: product,feature,incident,journey,surface,service,general',
1078
+ initialValue: Array.isArray(mem.overlay) ? mem.overlay.join(',') : 'general',
1079
+ validate: (v) => {
1080
+ const parts = v.split(',').map(s => s.trim()).filter(Boolean);
1081
+ if (parts.length === 0) return 'At least one overlay required';
1082
+ if (parts.length > 3) return 'Max 3 overlays';
1083
+ const invalid = parts.filter(p => !VALID_OVERLAYS.includes(p));
1084
+ if (invalid.length) return `Invalid: ${invalid.join(', ')}. Valid: ${VALID_OVERLAYS.join(', ')}`;
1085
+ },
1086
+ });
1087
+ } catch {
1088
+ overlayInput = Array.isArray(mem.overlay) ? mem.overlay.join(',') : 'general';
1089
+ }
1090
+ if (isCancel(overlayInput)) return null;
1091
+
1092
+ let angleInput;
1093
+ try {
1094
+ angleInput = await text({
1095
+ message: `Angles (current: ${Array.isArray(mem.angle) ? mem.angle.join(',') : '-'})`,
1096
+ placeholder: 'comma-separated: business,market,product,ux,technical,operational,delivery',
1097
+ initialValue: Array.isArray(mem.angle) ? mem.angle.join(',') : 'technical',
1098
+ validate: (v) => {
1099
+ const parts = v.split(',').map(s => s.trim()).filter(Boolean);
1100
+ if (parts.length === 0) return 'At least one angle required';
1101
+ if (parts.length > 2) return 'Max 2 angles';
1102
+ const invalid = parts.filter(p => !VALID_ANGLES.includes(p));
1103
+ if (invalid.length) return `Invalid: ${invalid.join(', ')}. Valid: ${VALID_ANGLES.join(', ')}`;
1104
+ },
1105
+ });
1106
+ } catch {
1107
+ angleInput = Array.isArray(mem.angle) ? mem.angle.join(',') : 'technical';
1108
+ }
1109
+ if (isCancel(angleInput)) return null;
1110
+
1111
+ return {
1112
+ layer: newLayer,
1113
+ overlay: overlayInput.split(',').map(s => s.trim()).filter(Boolean),
1114
+ angle: angleInput.split(',').map(s => s.trim()).filter(Boolean),
1115
+ };
1116
+ }
1117
+
1118
+ // ── scope-promote ────────────────────────────────────────────────────
1119
+
1120
+ /**
1121
+ * Auto-promote memories observed in 2+ repos to namespace scope.
1122
+ * PRD §4.3.1: personal → repo → namespace → global promotion flow.
1123
+ */
1124
+ async function memoryScopePromote(args) {
1125
+ fmt.intro('aw memory scope-promote');
1126
+
1127
+ const dryRun = args['--dry-run'] === true;
1128
+ const minRepos = parseInt(args['--min-repos'] || '2', 10);
1129
+
1130
+ const s = fmt.spinner();
1131
+ s.start('Fetching repo-scoped memories...');
1132
+
1133
+ try {
1134
+ // Get all active memories with repo_slug set (repo-scoped)
1135
+ const result = await callMemoryTool('memory_search', {
1136
+ query: '*',
1137
+ limit: 500,
1138
+ });
1139
+ const memories = Array.isArray(result) ? result : (result?.memories ?? result?.results ?? []);
1140
+
1141
+ if (memories.length === 0) {
1142
+ s.stop('No memories found');
1143
+ fmt.outro('Nothing to promote');
1144
+ return;
1145
+ }
1146
+
1147
+ // Group by content similarity key (first 100 chars normalized)
1148
+ const contentGroups = new Map(); // normalizedContent → [{ mem, repo_slug }]
1149
+
1150
+ for (const mem of memories) {
1151
+ if (!mem.repo_slug) continue; // Skip non-repo-scoped memories
1152
+ if (mem.scope_level === 'global' || mem.scope_level === 'namespace') continue; // Already promoted
1153
+
1154
+ const key = normalizeForGrouping(mem.content || mem.text || '');
1155
+ if (!key) continue;
1156
+
1157
+ if (!contentGroups.has(key)) contentGroups.set(key, []);
1158
+ contentGroups.get(key).push({ mem, repo_slug: mem.repo_slug });
1159
+ }
1160
+
1161
+ // Find memories that appear in minRepos+ different repos
1162
+ const promotable = [];
1163
+ for (const [key, entries] of contentGroups) {
1164
+ const uniqueRepos = new Set(entries.map(e => e.repo_slug));
1165
+ if (uniqueRepos.size >= minRepos) {
1166
+ // Pick the highest-confidence version as the canonical one
1167
+ const sorted = entries.sort((a, b) => (b.mem.confidence ?? 0) - (a.mem.confidence ?? 0));
1168
+ promotable.push({
1169
+ canonical: sorted[0].mem,
1170
+ repos: [...uniqueRepos],
1171
+ count: entries.length,
1172
+ });
1173
+ }
1174
+ }
1175
+
1176
+ s.stop(`Found ${promotable.length} memor${promotable.length === 1 ? 'y' : 'ies'} in ${minRepos}+ repos`);
1177
+
1178
+ if (promotable.length === 0) {
1179
+ fmt.logInfo(`No memories found in ${minRepos}+ different repos.`);
1180
+ fmt.outro('Nothing to promote');
1181
+ return;
1182
+ }
1183
+
1184
+ // Display candidates
1185
+ for (const p of promotable) {
1186
+ const content = (p.canonical.content || '').slice(0, 100);
1187
+ fmt.note(
1188
+ `${chalk.dim('content:')} ${content}\n` +
1189
+ `${chalk.dim('repos:')} ${p.repos.join(', ')}\n` +
1190
+ `${chalk.dim('conf:')} ${p.canonical.confidence ?? '-'}`,
1191
+ `${p.repos.length} repos, ${p.count} occurrences`,
1192
+ );
1193
+ }
1194
+
1195
+ if (dryRun) {
1196
+ fmt.logInfo('Dry run — no changes made.');
1197
+ fmt.outro(`${promotable.length} memories would be promoted to namespace scope`);
1198
+ return;
1199
+ }
1200
+
1201
+ // Confirm
1202
+ if (!args['--yes'] && !args['-y']) {
1203
+ try {
1204
+ const shouldPromote = await confirm({
1205
+ message: `Promote ${promotable.length} memor${promotable.length !== 1 ? 'ies' : 'y'} to namespace scope?`,
1206
+ });
1207
+ if (isCancel(shouldPromote) || !shouldPromote) {
1208
+ fmt.outro('Promotion cancelled');
1209
+ return;
1210
+ }
1211
+ } catch {
1212
+ // Non-TTY — proceed
1213
+ }
1214
+ }
1215
+
1216
+ // Promote each by updating scope_level to 'namespace' and clearing repo_slug
1217
+ const ps = fmt.spinner();
1218
+ ps.start('Promoting to namespace scope...');
1219
+
1220
+ let promoted = 0;
1221
+ let failed = 0;
1222
+
1223
+ for (const p of promotable) {
1224
+ try {
1225
+ await callMemoryTool('memory_update', {
1226
+ memory_id: p.canonical.id,
1227
+ scope_level: 'namespace',
1228
+ repo_slug: null,
1229
+ });
1230
+ promoted++;
1231
+ } catch (err) {
1232
+ failed++;
1233
+ fmt.logError(`Failed to promote ${(p.canonical.id || '').slice(0, 8)}: ${err.message}`);
1234
+ }
1235
+ }
1236
+
1237
+ ps.stop('Promotion complete');
1238
+
1239
+ const resultLines = [
1240
+ ` ${chalk.green('Promoted:')} ${promoted}`,
1241
+ failed > 0 ? ` ${chalk.red('Failed:')} ${failed}` : null,
1242
+ ].filter(Boolean).join('\n');
1243
+ fmt.note(resultLines, 'Scope Promote Summary');
1244
+ fmt.outro(`${promoted} memor${promoted !== 1 ? 'ies' : 'y'} promoted to namespace scope`);
1245
+ } catch (err) {
1246
+ s.stop(chalk.red('Failed'));
1247
+ fmt.cancel(`Scope promote failed: ${err.message}`);
1248
+ }
1249
+ }
1250
+
1251
+ /**
1252
+ * Normalize memory content for grouping — lowercase, strip whitespace, truncate.
1253
+ */
1254
+ function normalizeForGrouping(content) {
1255
+ if (!content) return '';
1256
+ return content
1257
+ .toLowerCase()
1258
+ .replace(/\s+/g, ' ')
1259
+ .trim()
1260
+ .slice(0, 100);
1261
+ }
1262
+
825
1263
  // ── help ─────────────────────────────────────────────────────────────
826
1264
 
827
1265
  function memoryHelp() {
@@ -877,7 +1315,14 @@ function memoryHelp() {
877
1315
  cmd(' --limit <n>', 'Observations to scan (default: 200)'),
878
1316
  cmd(' --min <n>', 'Min occurrences to promote (default: 3)'),
879
1317
  '',
1318
+ cmd('aw memory scope-promote', 'Promote repo→namespace scope'),
1319
+ cmd(' --min-repos <n>', 'Min repos to trigger (default: 2)'),
1320
+ cmd(' --dry-run', 'Preview without changes'),
1321
+ '',
880
1322
  ].join('\n');
881
1323
 
882
1324
  console.log(help);
883
1325
  }
1326
+
1327
+ // ── test-only exports ────────────────────────────────────────────────
1328
+ export const _test = { rateMemoryQuality, getQualityIssue, buildPromoteContent, normalizeForGrouping, formatTimeAgo };
package/constants.mjs CHANGED
@@ -37,3 +37,6 @@ export const AW_CO_AUTHOR = `Co-Authored-By: ${AW_BOT_NAME} <${AW_BOT_EMAIL}>`;
37
37
 
38
38
  /** MCP base URL for memory and other tool calls — override with AW_MCP_URL env var */
39
39
  export const MCP_BASE_URL = process.env.AW_MCP_URL || 'http://localhost:3100/agentic-workspace/mcp';
40
+
41
+ /** Default org-level namespace — always included in ancestry */
42
+ export const ORG_SCOPE_NAMESPACE = 'platform';
package/memory-bridge.mjs CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { execSync } from 'node:child_process';
4
4
  import { join } from 'node:path';
5
- import { MCP_BASE_URL, AW_HOME, REGISTRY_DIR } from './constants.mjs';
5
+ import { MCP_BASE_URL, AW_HOME, REGISTRY_DIR, ORG_SCOPE_NAMESPACE } 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
 
@@ -23,9 +24,21 @@ function resolveHeaders() {
23
24
  'Accept': 'application/json, text/event-stream',
24
25
  };
25
26
 
26
- // Resolve namespace from .sync-config.json → X-Namespace header
27
+ // Resolve namespace paths from .sync-config.json
27
28
  const registryDir = join(AW_HOME, REGISTRY_DIR);
28
29
  const cfg = config.load(registryDir);
30
+
31
+ // Send ALL initialized namespace paths (from include[]) + platform
32
+ const paths = [...(cfg?.include || [])];
33
+ if (!paths.some(p => p === ORG_SCOPE_NAMESPACE || p.startsWith(ORG_SCOPE_NAMESPACE + '/'))) {
34
+ paths.push(ORG_SCOPE_NAMESPACE);
35
+ }
36
+ headers['X-Namespace-Paths'] = paths.join(',');
37
+
38
+ // Send GitHub username
39
+ headers['X-Github-User'] = cfg?.user || '';
40
+
41
+ // Keep X-Namespace for backwards compat
29
42
  if (cfg?.namespace) {
30
43
  headers['X-Namespace'] = cfg.namespace;
31
44
  }
@@ -55,16 +68,151 @@ function resolveGhToken() {
55
68
  return null;
56
69
  }
57
70
 
71
+ /**
72
+ * Compute the full ancestry chain for a set of namespace paths.
73
+ * E.g. ['platform/scheduling/calendars'] → ['platform/scheduling/calendars', 'platform/scheduling', 'platform']
74
+ */
75
+ export function computeAncestry(paths) {
76
+ const result = new Set();
77
+ for (const path of paths) {
78
+ const segments = path.split('/');
79
+ for (let i = segments.length; i >= 1; i--) {
80
+ result.add(segments.slice(0, i).join('/'));
81
+ }
82
+ }
83
+ result.add(ORG_SCOPE_NAMESPACE);
84
+ return [...result];
85
+ }
86
+
87
+ /**
88
+ * Resolve the store namespace using layered defaults.
89
+ * Layer 1: Explicit (from --namespace flag)
90
+ * Layer 2: Single include path
91
+ * Layer 3: Multi include — return null (curation picks)
92
+ * Layer 4: Fallback to cfg.namespace or 'platform'
93
+ */
94
+ export function resolveStoreNamespace(cfg, explicitNs) {
95
+ // Layer 1: Explicit
96
+ if (explicitNs) return explicitNs;
97
+ // Layer 2: Single include
98
+ const includes = cfg?.include || [];
99
+ if (includes.length === 1) return includes[0];
100
+ // Layer 3: Multi include — return null (curation picks)
101
+ if (includes.length > 1) return null;
102
+ // Layer 4: Fallback
103
+ return cfg?.namespace || ORG_SCOPE_NAMESPACE;
104
+ }
105
+
106
+ /**
107
+ * Resolve read namespace paths using layered defaults.
108
+ * Returns a full ancestry chain for the resolved paths.
109
+ */
110
+ export function resolveReadPaths(cfg, explicitNs) {
111
+ // Layer 1: Explicit
112
+ if (explicitNs) return computeAncestry([explicitNs]);
113
+ // Layer 2: Single include
114
+ const includes = cfg?.include || [];
115
+ if (includes.length === 1) return computeAncestry(includes);
116
+ // Layer 3: Multi include
117
+ if (includes.length > 1) return computeAncestry(includes);
118
+ // Layer 4: Fallback
119
+ return [ORG_SCOPE_NAMESPACE];
120
+ }
121
+
122
+ /** Write operations that can be queued when MCP is unreachable. */
123
+ const WRITE_OPS = new Set([
124
+ 'memory_store',
125
+ 'memory_curated_store',
126
+ 'memory_feedback',
127
+ 'memory_update',
128
+ 'memory_invalidate',
129
+ ]);
130
+
131
+ /** Detect network-level errors (MCP unreachable). */
132
+ function isNetworkError(err) {
133
+ if (!err) return false;
134
+ const msg = err.message || '';
135
+ const code = err.code || '';
136
+ return (
137
+ code === 'ECONNREFUSED' ||
138
+ code === 'ECONNRESET' ||
139
+ code === 'ENOTFOUND' ||
140
+ code === 'UND_ERR_CONNECT_TIMEOUT' ||
141
+ msg.includes('fetch failed') ||
142
+ msg.includes('network') ||
143
+ msg.includes('ECONNREFUSED') ||
144
+ msg.includes('ECONNRESET') ||
145
+ msg.includes('ENOTFOUND') ||
146
+ err.name === 'AbortError' ||
147
+ err.name === 'TimeoutError'
148
+ );
149
+ }
150
+
151
+ /** Flag to prevent recursive flush attempts. */
152
+ let _flushing = false;
153
+
154
+ /**
155
+ * Try to flush queued operations if MCP is reachable.
156
+ * Called at the start of every successful callMemoryTool invocation.
157
+ */
158
+ async function tryFlushQueue() {
159
+ if (_flushing) return;
160
+ _flushing = true;
161
+ try {
162
+ await flushQueue(_callMemoryToolRaw);
163
+ } catch {
164
+ // Flush is best-effort — don't block the current operation
165
+ } finally {
166
+ _flushing = false;
167
+ }
168
+ }
169
+
58
170
  /**
59
171
  * Call an MCP memory tool by name via JSON-RPC 2.0 (Streamable HTTP).
172
+ * Wraps the raw transport with offline queue support:
173
+ * - WRITE ops are enqueued when MCP is unreachable
174
+ * - READ ops propagate the error
175
+ * - On success, flushes any queued operations
60
176
  * @param {string} toolName — MCP tool name (e.g. 'memory_curated_store', 'memory_search')
61
177
  * @param {object} params — Tool arguments
62
- * @returns {Promise<object>} Parsed tool result
178
+ * @param {object} [opts] — Optional overrides
179
+ * @param {string} [opts.storeNamespace] — If set, adds X-Store-Namespace header
180
+ * @param {string} [opts.namespacePaths] — If set, overrides X-Namespace-Paths header
181
+ * @returns {Promise<object>} Parsed tool result (or synthetic {queued:true})
63
182
  */
64
- export async function callMemoryTool(toolName, params) {
183
+ export async function callMemoryTool(toolName, params, opts = {}) {
184
+ try {
185
+ const result = await _callMemoryToolRaw(toolName, params, opts);
186
+ // MCP is reachable — flush any queued items in the background
187
+ tryFlushQueue();
188
+ return result;
189
+ } catch (err) {
190
+ if (isNetworkError(err) && WRITE_OPS.has(toolName)) {
191
+ enqueue(toolName, params);
192
+ return { queued: true, message: 'MCP unreachable — operation queued for retry' };
193
+ }
194
+ throw err;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Raw MCP transport — JSON-RPC 2.0 over Streamable HTTP.
200
+ * @param {string} toolName
201
+ * @param {object} params
202
+ * @param {object} [opts]
203
+ * @returns {Promise<object>}
204
+ */
205
+ async function _callMemoryToolRaw(toolName, params, opts = {}) {
206
+ const headers = { ...resolveHeaders() };
207
+ if (opts.storeNamespace) {
208
+ headers['X-Store-Namespace'] = opts.storeNamespace;
209
+ }
210
+ if (opts.namespacePaths) {
211
+ headers['X-Namespace-Paths'] = opts.namespacePaths;
212
+ }
65
213
  const response = await fetch(MCP_BASE_URL, {
66
214
  method: 'POST',
67
- headers: resolveHeaders(),
215
+ headers,
68
216
  body: JSON.stringify({
69
217
  jsonrpc: '2.0',
70
218
  id: _rpcId++,
@@ -0,0 +1,131 @@
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 (resolved lazily for testability) */
8
+ function getQueuePath() { return 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 queuePath = getQueuePath();
20
+ const dir = dirname(queuePath);
21
+ if (!existsSync(dir)) {
22
+ mkdirSync(dir, { recursive: true });
23
+ }
24
+
25
+ const entry = {
26
+ op,
27
+ payload,
28
+ queued_at: new Date().toISOString(),
29
+ attempt: 0,
30
+ };
31
+
32
+ appendFileSync(queuePath, JSON.stringify(entry) + '\n', 'utf8');
33
+ }
34
+
35
+ /**
36
+ * Flush the offline queue — process each item via the provided sender function.
37
+ * Removes successfully processed items, retries failed ones (increments attempt),
38
+ * and discards items older than 7 days.
39
+ * @param {(toolName: string, params: object) => Promise<object>} sender — raw MCP call function
40
+ * @returns {Promise<{processed: number, failed: number, discarded: number}>}
41
+ */
42
+ export async function flushQueue(sender) {
43
+ const queuePath = getQueuePath();
44
+ if (!existsSync(queuePath)) {
45
+ return { processed: 0, failed: 0, discarded: 0 };
46
+ }
47
+
48
+ const raw = readFileSync(queuePath, 'utf8').trim();
49
+ if (!raw) {
50
+ return { processed: 0, failed: 0, discarded: 0 };
51
+ }
52
+
53
+ const lines = raw.split('\n').filter(Boolean);
54
+ const remaining = [];
55
+ let processed = 0;
56
+ let failed = 0;
57
+ let discarded = 0;
58
+ const now = Date.now();
59
+
60
+ for (const line of lines) {
61
+ let entry;
62
+ try {
63
+ entry = JSON.parse(line);
64
+ } catch {
65
+ // Malformed line — discard
66
+ discarded++;
67
+ continue;
68
+ }
69
+
70
+ // Discard items older than 7 days
71
+ const age = now - new Date(entry.queued_at).getTime();
72
+ if (age > MAX_AGE_MS) {
73
+ discarded++;
74
+ continue;
75
+ }
76
+
77
+ try {
78
+ await sender(entry.op, entry.payload);
79
+ processed++;
80
+ } catch {
81
+ entry.attempt = (entry.attempt || 0) + 1;
82
+ remaining.push(entry);
83
+ failed++;
84
+ }
85
+ }
86
+
87
+ // Rewrite queue with only the remaining (failed) items
88
+ if (remaining.length > 0) {
89
+ const data = remaining.map(e => JSON.stringify(e)).join('\n') + '\n';
90
+ writeFileSync(queuePath, data, 'utf8');
91
+ } else {
92
+ writeFileSync(queuePath, '', 'utf8');
93
+ }
94
+
95
+ return { processed, failed, discarded };
96
+ }
97
+
98
+ /**
99
+ * Get queue statistics for display.
100
+ * @returns {{pending: number, oldest: string|null, newest: string|null}}
101
+ */
102
+ export function getQueueStats() {
103
+ const queuePath = getQueuePath();
104
+ if (!existsSync(queuePath)) {
105
+ return { pending: 0, oldest: null, newest: null };
106
+ }
107
+
108
+ const raw = readFileSync(queuePath, 'utf8').trim();
109
+ if (!raw) {
110
+ return { pending: 0, oldest: null, newest: null };
111
+ }
112
+
113
+ const lines = raw.split('\n').filter(Boolean);
114
+ let oldest = null;
115
+ let newest = null;
116
+ let pending = 0;
117
+
118
+ for (const line of lines) {
119
+ try {
120
+ const entry = JSON.parse(line);
121
+ pending++;
122
+ const ts = entry.queued_at;
123
+ if (!oldest || ts < oldest) oldest = ts;
124
+ if (!newest || ts > newest) newest = ts;
125
+ } catch {
126
+ // skip malformed
127
+ }
128
+ }
129
+
130
+ return { pending, oldest, newest };
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.37-beta.43",
3
+ "version": "0.1.37-beta.45",
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"
@@ -44,7 +45,7 @@
44
45
  "license": "MIT",
45
46
  "scripts": {
46
47
  "test": "yarn test:vitest && yarn test:node",
47
- "test:vitest": "vitest run --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs",
48
+ "test:vitest": "vitest run --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs tests/memory-queue.test.mjs tests/memory-sync.test.mjs tests/memory-bridge.test.mjs",
48
49
  "test:node": "node tests/run-node-tests.mjs",
49
50
  "test:watch": "vitest --reporter=verbose tests/commands tests/mcp.test.mjs tests/telemetry.test.mjs",
50
51
  "preuninstall": "node bin.js nuke 2>/dev/null || true"