@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.
@@ -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': return memoryReview(args);
25
- case 'promote': return memoryPromote(args);
26
- default: return memoryHelp();
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:')} ${validated}`,
579
- ` ${chalk.red('Invalidated:')} ${invalidated}`,
580
- ` ${chalk.dim('Skipped:')} ${skipped}`,
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(),
@@ -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.43",
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"