@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 +3 -1
- package/commands/memory.mjs +461 -16
- package/constants.mjs +3 -0
- package/memory-bridge.mjs +153 -5
- package/memory-queue.mjs +131 -0
- package/package.json +4 -3
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) {
|
package/commands/memory.mjs
CHANGED
|
@@ -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':
|
|
25
|
-
case 'promote':
|
|
26
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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:')}
|
|
579
|
-
` ${chalk.red('Invalidated:')}
|
|
580
|
-
` ${chalk.
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
215
|
+
headers,
|
|
68
216
|
body: JSON.stringify({
|
|
69
217
|
jsonrpc: '2.0',
|
|
70
218
|
id: _rpcId++,
|
package/memory-queue.mjs
ADDED
|
@@ -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.
|
|
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"
|