@pyxmate/memory 0.20.4 → 0.20.5

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/bin/init.mjs CHANGED
@@ -1,43 +1,312 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
4
- import { dirname, join, resolve } from 'node:path';
3
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs';
4
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const DEFAULT_ENDPOINT = 'https://memory.api.pyxmate.com';
8
9
 
9
- const command = process.argv[2];
10
+ // ---------- Argument parsing ----------
10
11
 
11
- if (command !== 'init') {
12
- console.log(`Usage: pyxmate-memory init
12
+ /**
13
+ * Minimal long-flag parser. Supports `--key=value`, `--key value`, boolean `--flag`,
14
+ * `--` terminator, and positional rest. Unknown subcommand-specific flags surface
15
+ * as keys in `flags`; the dispatcher decides if any are unsupported.
16
+ */
17
+ function parseArgs(argv) {
18
+ const flags = Object.create(null);
19
+ const positional = [];
20
+ let i = 0;
21
+ while (i < argv.length) {
22
+ const a = argv[i];
23
+ if (a === '--') {
24
+ positional.push(...argv.slice(i + 1));
25
+ break;
26
+ }
27
+ if (a.startsWith('--')) {
28
+ const eq = a.indexOf('=');
29
+ if (eq >= 0) {
30
+ const key = a.slice(2, eq);
31
+ flags[key] = a.slice(eq + 1);
32
+ i += 1;
33
+ continue;
34
+ }
35
+ const key = a.slice(2);
36
+ const next = argv[i + 1];
37
+ if (next === undefined || next.startsWith('--')) {
38
+ flags[key] = true;
39
+ i += 1;
40
+ } else {
41
+ flags[key] = next;
42
+ i += 2;
43
+ }
44
+ continue;
45
+ }
46
+ positional.push(a);
47
+ i += 1;
48
+ }
49
+ return { flags, positional };
50
+ }
13
51
 
14
- Commands:
15
- init Copy pyx-memory skill files into .claude/skills/ for Claude Code auto-discovery`);
16
- process.exit(command ? 1 : 0);
52
+ // ---------- Errors ----------
53
+
54
+ class CliError extends Error {
55
+ constructor(message, exitCode = 1) {
56
+ super(message);
57
+ this.exitCode = exitCode;
58
+ }
17
59
  }
18
60
 
19
- const source = resolve(__dirname, '..', 'skills', 'pyx-memory');
20
- const target = resolve(process.cwd(), '.claude', 'skills', 'pyx-memory');
61
+ // ---------- Shared helpers ----------
21
62
 
22
- if (!existsSync(source)) {
23
- console.error('Error: Skills directory not found in package. Please reinstall @pyxmate/memory.');
24
- process.exit(1);
63
+ function resolveAuth(flags) {
64
+ const endpointRaw = flags.endpoint ?? process.env.PYXMATE_ENDPOINT ?? DEFAULT_ENDPOINT;
65
+ if (typeof endpointRaw !== 'string') {
66
+ throw new CliError('--endpoint requires a value (got bare flag)', 2);
67
+ }
68
+ const endpoint = endpointRaw.replace(/\/+$/, '');
69
+ const apiKey = flags['api-key'] ?? process.env.PYXMATE_API_KEY;
70
+ if (!apiKey || typeof apiKey !== 'string') {
71
+ throw new CliError('missing API key: set PYXMATE_API_KEY or pass --api-key <key>', 2);
72
+ }
73
+ return { apiKey, endpoint };
74
+ }
75
+
76
+ function buildHeaders(flags, { apiKey, json = false, includeSensitivity = false } = {}) {
77
+ const headers = { Authorization: `Bearer ${apiKey}` };
78
+ if (json) headers['Content-Type'] = 'application/json';
79
+ // Bare `--tenant-id`/`--user-id`/`--team-id` (no value) becomes `true` via parseArgs;
80
+ // `String(true)` would silently scope the request to a tenant literally named "true".
81
+ // Type-guard mirrors resolveAuth's `--endpoint` shape.
82
+ for (const [flag, header] of [
83
+ ['tenant-id', 'X-Tenant-Id'],
84
+ ['user-id', 'X-User-Id'],
85
+ ['team-id', 'X-Team-Id'],
86
+ ]) {
87
+ if (flags[flag] !== undefined) {
88
+ if (typeof flags[flag] !== 'string') {
89
+ throw new CliError(`--${flag} requires a value (got bare flag)`, 2);
90
+ }
91
+ headers[header] = flags[flag];
92
+ }
93
+ }
94
+ if (includeSensitivity && flags.sensitivity) {
95
+ const level = String(flags.sensitivity);
96
+ if (!['public', 'internal', 'secret'].includes(level)) {
97
+ throw new CliError(
98
+ `--sensitivity must be one of: public, internal, secret (got '${level}')`,
99
+ 2,
100
+ );
101
+ }
102
+ headers['X-Caller-Access-Level'] = level;
103
+ }
104
+ return headers;
25
105
  }
26
106
 
27
- // Ensure target parent exists
28
- mkdirSync(dirname(target), { recursive: true });
107
+ function loadJsonArg(value, flagName) {
108
+ if (value === undefined || value === null || value === true) {
109
+ throw new CliError(`--${flagName} requires a value`, 2);
110
+ }
111
+ const raw = String(value);
112
+ let source;
113
+ let payload;
114
+ try {
115
+ if (raw === '-') {
116
+ source = 'stdin';
117
+ // Synchronous stdin read for ergonomics; only invoked when caller explicitly passes '-'.
118
+ payload = readFileSync(0, 'utf8');
119
+ } else if (raw.startsWith('@')) {
120
+ source = `file ${raw.slice(1)}`;
121
+ payload = readFileSync(raw.slice(1), 'utf8');
122
+ } else {
123
+ source = 'inline';
124
+ payload = raw;
125
+ }
126
+ } catch (err) {
127
+ throw new CliError(`--${flagName}: failed to read ${source}: ${err.message}`, 2);
128
+ }
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(payload);
132
+ } catch (err) {
133
+ throw new CliError(`--${flagName}: invalid JSON from ${source}: ${err.message}`, 2);
134
+ }
135
+ if (!Array.isArray(parsed)) {
136
+ throw new CliError(`--${flagName}: expected JSON array, got ${typeof parsed}`, 2);
137
+ }
138
+ return parsed;
139
+ }
29
140
 
30
- // Copy skills (overwrites existing files for idempotency)
31
- cpSync(source, target, { recursive: true });
141
+ async function request(method, url, { headers = {}, body = undefined } = {}) {
142
+ let response;
143
+ try {
144
+ response = await fetch(url, { method, headers, body });
145
+ } catch (err) {
146
+ throw new CliError(`network error: ${err.message}`, 1);
147
+ }
148
+ const text = await response.text();
149
+ let parsed;
150
+ if (text.length === 0) {
151
+ parsed = null;
152
+ } else {
153
+ try {
154
+ parsed = JSON.parse(text);
155
+ } catch {
156
+ // Server returned non-JSON; surface raw text so the failure is visible.
157
+ if (!response.ok) {
158
+ throw new CliError(`HTTP ${response.status}: ${text.slice(0, 500)}`, 1);
159
+ }
160
+ throw new CliError(`expected JSON response from ${url}; got: ${text.slice(0, 200)}`, 1);
161
+ }
162
+ }
163
+ if (!response.ok) {
164
+ const msg =
165
+ parsed && (parsed.error || parsed.message)
166
+ ? parsed.error || parsed.message
167
+ : `HTTP ${response.status}`;
168
+ throw new CliError(`server error (${response.status}): ${msg}`, 1);
169
+ }
170
+ // Surface `{ success: false }` envelope errors.
171
+ if (parsed && parsed.success === false) {
172
+ const msg = parsed.error || parsed.message || 'unknown error';
173
+ throw new CliError(`server error: ${msg}`, 1);
174
+ }
175
+ return parsed;
176
+ }
177
+
178
+ function emit(result, flags, humanFn) {
179
+ if (flags.json) {
180
+ process.stdout.write(`${JSON.stringify(result)}\n`);
181
+ return;
182
+ }
183
+ const human = humanFn ? humanFn(result) : JSON.stringify(result);
184
+ if (human !== undefined && human !== null && human !== '') {
185
+ process.stdout.write(`${human}\n`);
186
+ }
187
+ }
188
+
189
+ // ---------- Help text ----------
190
+
191
+ const TOP_HELP = `pyxmate-memory — CLI for the pyx-memory HTTP API
192
+
193
+ Usage:
194
+ pyxmate-memory <command> [flags]
195
+
196
+ Commands:
197
+ init Copy pyx-memory skill files into ./.claude/skills/
198
+ store Store a memory entry (POST /api/memory/ingest)
199
+ search Search memory (GET /api/memory/search)
200
+ get <id> Fetch a single entry (GET /api/memory/:id)
201
+ delete <id> Delete an entry (DELETE /api/memory/:id)
202
+ log List recent entries (GET /api/memory/log)
203
+ summarize-entity <name> Summarize an entity (POST /api/memory/summarize-entity)
204
+ ingest-file <path> Upload a file (POST /api/memory/ingest/file, multipart NDJSON)
205
+
206
+ Auth (operation commands):
207
+ PYXMATE_API_KEY=<key> API key (or --api-key <key>)
208
+ PYXMATE_ENDPOINT=<url> Endpoint (or --endpoint <url>, default ${DEFAULT_ENDPOINT})
209
+
210
+ Global flags (operation commands):
211
+ --tenant-id <id> Forward X-Tenant-Id header
212
+ --user-id <id> Forward X-User-Id header
213
+ --team-id <id> Forward X-Team-Id header
214
+ --json Emit raw upstream JSON to stdout
215
+ --help Print help for the command
216
+
217
+ Run \`pyxmate-memory <command> --help\` for command-specific flags.`;
218
+
219
+ const SUB_HELP = {
220
+ init: `Usage: pyxmate-memory init
221
+
222
+ Copies skill files into ./.claude/skills/pyx-memory/ for Claude Code auto-discovery.
223
+ Requires no API key; idempotent overwrite.`,
224
+ store: `Usage: pyxmate-memory store --content <text> [flags]
225
+
226
+ --content <text> Memory content (required)
227
+ --type <type> Memory type (default: long-term)
228
+ --topic <topic> Topic (metadata.topic)
229
+ --project <name> Project (metadata.project)
230
+ --importance <1-10> Importance score (metadata.importance)
231
+ --source <source> Source (metadata.source)
232
+ --event-time <iso> Event time (metadata.eventTime)
233
+ --entities <json|@path|-> JSON array of entities (inline, @file, or - for stdin)
234
+ --relationships <json|@path|-> JSON array of relationships (inline, @file, or - for stdin)
235
+ --quiet Print only the new entry id`,
236
+ search: `Usage: pyxmate-memory search --query <text> [flags]
237
+
238
+ --query <text> Search query (required; also accepts --q)
239
+ --limit <n> Max results (default 5)
240
+ --strategy <s> hybrid | vector | keyword | graph
241
+ --type <t> Filter by memory type
242
+ --agent-id <id> Filter by agentId
243
+ --abstention-threshold <0-1> Enable confidence scoring
244
+ --sensitivity <level> X-Caller-Access-Level (public|internal|secret)`,
245
+ get: `Usage: pyxmate-memory get <id> [flags]`,
246
+ delete: `Usage: pyxmate-memory delete <id> [flags]`,
247
+ log: `Usage: pyxmate-memory log [flags]
248
+
249
+ --since <iso> Only entries created at or after this ISO timestamp
250
+ --limit <n> Max entries
251
+ --type <t> Filter by memory type
252
+ --agent-id <id> Filter by agentId
253
+ --source <s> Filter by source`,
254
+ 'summarize-entity': `Usage: pyxmate-memory summarize-entity <name> [flags]`,
255
+ 'ingest-file': `Usage: pyxmate-memory ingest-file <path> [flags]
256
+
257
+ --description <text> Required for images; describes the file contents
258
+ --namespace-id <id> Namespace for the entry`,
259
+ };
260
+
261
+ const GLOBAL_FLAGS_FOOTER = `
262
+ Global flags:
263
+ --api-key <key> API key (or PYXMATE_API_KEY)
264
+ --endpoint <url> Endpoint URL (or PYXMATE_ENDPOINT)
265
+ --tenant-id <id> Forward X-Tenant-Id header
266
+ --user-id <id> Forward X-User-Id header
267
+ --team-id <id> Forward X-Team-Id header
268
+ --json Emit raw upstream JSON to stdout`;
269
+
270
+ function printHelp(subcommand) {
271
+ if (subcommand && SUB_HELP[subcommand]) {
272
+ const footer = subcommand === 'init' ? '' : `\n${GLOBAL_FLAGS_FOOTER}`;
273
+ process.stdout.write(`${SUB_HELP[subcommand]}${footer}\n`);
274
+ return;
275
+ }
276
+ process.stdout.write(`${TOP_HELP}\n`);
277
+ }
278
+
279
+ // ---------- init (existing behavior) ----------
280
+
281
+ function runInit() {
282
+ const source = resolve(__dirname, '..', 'skills', 'pyx-memory');
283
+ const target = resolve(process.cwd(), '.claude', 'skills', 'pyx-memory');
284
+
285
+ if (!existsSync(source)) {
286
+ throw new CliError(
287
+ 'Skills directory not found in package. Please reinstall @pyxmate/memory.',
288
+ 1,
289
+ );
290
+ }
291
+
292
+ mkdirSync(dirname(target), { recursive: true });
293
+ cpSync(source, target, { recursive: true });
294
+
295
+ const fileCount = countFiles(target);
296
+ const fileList = listFiles(target);
297
+
298
+ process.stdout.write(`Copied ${fileCount} skill files to .claude/skills/pyx-memory/\n\n`);
299
+ process.stdout.write(`${fileList.join('\n')}\n`);
300
+ process.stdout.write('\nClaude Code will now auto-discover pyx-memory integration patterns.\n');
301
+ }
32
302
 
33
- // Count copied files
34
303
  function countFiles(dir) {
35
304
  let count = 0;
36
305
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
37
306
  if (entry.isDirectory()) {
38
307
  count += countFiles(join(dir, entry.name));
39
308
  } else {
40
- count++;
309
+ count += 1;
41
310
  }
42
311
  }
43
312
  return count;
@@ -55,9 +324,349 @@ function listFiles(dir, prefix = '') {
55
324
  return lines;
56
325
  }
57
326
 
58
- const fileCount = countFiles(target);
59
- const fileList = listFiles(target);
327
+ // ---------- Operation subcommands ----------
328
+
329
+ async function runStore(flags) {
330
+ if (!flags.content || flags.content === true) {
331
+ throw new CliError('--content is required', 2);
332
+ }
333
+ const { apiKey, endpoint } = resolveAuth(flags);
334
+ const headers = buildHeaders(flags, { apiKey, json: true });
335
+
336
+ const metadata = {};
337
+ if (flags.topic) metadata.topic = String(flags.topic);
338
+ if (flags.project) metadata.project = String(flags.project);
339
+
340
+ const body = {
341
+ content: String(flags.content),
342
+ type: flags.type ? String(flags.type) : 'long-term',
343
+ };
344
+ if (Object.keys(metadata).length > 0) body.metadata = metadata;
345
+ if (flags.source) body.source = String(flags.source);
346
+ if (flags.importance !== undefined) {
347
+ const n = Number(flags.importance);
348
+ if (!Number.isInteger(n) || n < 1 || n > 10) {
349
+ throw new CliError(
350
+ `--importance must be an integer in [1,10] (got '${flags.importance}')`,
351
+ 2,
352
+ );
353
+ }
354
+ body.importance = n;
355
+ }
356
+ if (flags['event-time']) {
357
+ const iso = String(flags['event-time']);
358
+ if (
359
+ !/^\d{4}-\d{2}-\d{2}T/.test(iso) ||
360
+ !/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ||
361
+ !Number.isFinite(new Date(iso).getTime())
362
+ ) {
363
+ throw new CliError(
364
+ `--event-time must be ISO 8601 (e.g. 2026-04-15T10:00:00Z); got '${iso}'`,
365
+ 2,
366
+ );
367
+ }
368
+ body.eventTime = iso;
369
+ }
370
+ if (flags.entities !== undefined) body.entities = loadJsonArg(flags.entities, 'entities');
371
+ if (flags.relationships !== undefined)
372
+ body.relationships = loadJsonArg(flags.relationships, 'relationships');
373
+
374
+ const result = await request('POST', `${endpoint}/api/memory/ingest`, {
375
+ headers,
376
+ body: JSON.stringify(body),
377
+ });
378
+
379
+ if (flags.quiet) {
380
+ const id = (result && result.data && result.data.id) || (result && result.id);
381
+ if (!id) throw new CliError('--quiet: server response did not contain an id', 1);
382
+ process.stdout.write(`${id}\n`);
383
+ return;
384
+ }
385
+ emit(result, flags, (r) => {
386
+ const id = (r && r.data && r.data.id) || (r && r.id) || '<no id>';
387
+ return `stored ${id}`;
388
+ });
389
+ }
390
+
391
+ async function runSearch(flags) {
392
+ const query = flags.query ?? flags.q;
393
+ if (!query || query === true) throw new CliError('--query is required', 2);
394
+ const { apiKey, endpoint } = resolveAuth(flags);
395
+ const headers = buildHeaders(flags, { apiKey, includeSensitivity: true });
396
+
397
+ const params = new URLSearchParams();
398
+ // Cloud reads `q`; self-hosted reads `query`. Send both for runtime compat.
399
+ params.set('query', String(query));
400
+ params.set('q', String(query));
401
+ if (flags.limit !== undefined) params.set('limit', String(flags.limit));
402
+ if (flags.strategy) params.set('strategy', String(flags.strategy));
403
+ if (flags.type) params.set('type', String(flags.type));
404
+ if (flags['agent-id']) params.set('agentId', String(flags['agent-id']));
405
+ if (flags['abstention-threshold'] !== undefined) {
406
+ params.set('abstentionThreshold', String(flags['abstention-threshold']));
407
+ }
408
+
409
+ const result = await request('GET', `${endpoint}/api/memory/search?${params.toString()}`, {
410
+ headers,
411
+ });
412
+
413
+ emit(result, flags, (r) => {
414
+ const results = (r && r.data && r.data.results) || (r && r.results) || [];
415
+ if (!Array.isArray(results) || results.length === 0) return 'no results';
416
+ return results
417
+ .map((m, idx) => {
418
+ const id = m.id ?? '<no id>';
419
+ const content = (m.content ?? '').slice(0, 100);
420
+ const score = typeof m.score === 'number' ? ` [score=${m.score.toFixed(3)}]` : '';
421
+ return `${idx + 1}. ${id}${score} ${content}`;
422
+ })
423
+ .join('\n');
424
+ });
425
+ }
426
+
427
+ async function runGet(flags, positional) {
428
+ const id = positional[0];
429
+ if (!id) throw new CliError('get: <id> argument is required', 2);
430
+ const { apiKey, endpoint } = resolveAuth(flags);
431
+ const headers = buildHeaders(flags, { apiKey });
432
+ const result = await request('GET', `${endpoint}/api/memory/entries/${encodeURIComponent(id)}`, {
433
+ headers,
434
+ });
435
+ emit(result, flags, (r) => JSON.stringify(r, null, 2));
436
+ }
437
+
438
+ async function runDelete(flags, positional) {
439
+ const id = positional[0];
440
+ if (!id) throw new CliError('delete: <id> argument is required', 2);
441
+ const { apiKey, endpoint } = resolveAuth(flags);
442
+ const headers = buildHeaders(flags, { apiKey });
443
+ const result = await request(
444
+ 'DELETE',
445
+ `${endpoint}/api/memory/entries/${encodeURIComponent(id)}`,
446
+ { headers },
447
+ );
448
+ emit(result, flags, () => `deleted ${id}`);
449
+ }
450
+
451
+ async function runLog(flags) {
452
+ const { apiKey, endpoint } = resolveAuth(flags);
453
+ const headers = buildHeaders(flags, { apiKey });
454
+ const params = new URLSearchParams();
455
+ if (flags.since) params.set('since', String(flags.since));
456
+ if (flags.limit !== undefined) params.set('limit', String(flags.limit));
457
+ if (flags.type) params.set('type', String(flags.type));
458
+ if (flags['agent-id']) params.set('agentId', String(flags['agent-id']));
459
+ if (flags.source) params.set('source', String(flags.source));
460
+ const qs = params.toString();
461
+ const url = `${endpoint}/api/memory/log${qs ? `?${qs}` : ''}`;
462
+ const result = await request('GET', url, { headers });
463
+ emit(result, flags, (r) => {
464
+ const entries = (r && r.data && r.data.entries) || (r && r.entries) || [];
465
+ if (!Array.isArray(entries) || entries.length === 0) return 'no entries';
466
+ return entries
467
+ .map((e) => `${e.createdAt ?? ''} ${e.id ?? ''} ${(e.content ?? '').slice(0, 80)}`)
468
+ .join('\n');
469
+ });
470
+ }
471
+
472
+ async function runSummarizeEntity(flags, positional) {
473
+ const name = positional[0];
474
+ if (!name) throw new CliError('summarize-entity: <name> argument is required', 2);
475
+ const { apiKey, endpoint } = resolveAuth(flags);
476
+ const headers = buildHeaders(flags, { apiKey, json: true });
477
+ const result = await request('POST', `${endpoint}/api/memory/synthesis/entity`, {
478
+ headers,
479
+ body: JSON.stringify({ name }),
480
+ });
481
+ emit(result, flags, (r) => {
482
+ const summary = (r && r.data && r.data.summary) || (r && r.summary);
483
+ return summary ? `${name}: ${summary}` : JSON.stringify(r);
484
+ });
485
+ }
486
+
487
+ async function runIngestFile(flags, positional) {
488
+ const filePath = positional[0];
489
+ if (!filePath) throw new CliError('ingest-file: <path> argument is required', 2);
490
+ const absPath = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
491
+ let fileStat;
492
+ try {
493
+ fileStat = statSync(absPath);
494
+ } catch (err) {
495
+ throw new CliError(`ingest-file: cannot stat '${filePath}': ${err.message}`, 2);
496
+ }
497
+ if (!fileStat.isFile()) throw new CliError(`ingest-file: '${filePath}' is not a regular file`, 2);
498
+
499
+ const { apiKey, endpoint } = resolveAuth(flags);
500
+ const headers = buildHeaders(flags, { apiKey });
501
+
502
+ const buf = readFileSync(absPath);
503
+ const blob = new Blob([buf]);
504
+ const form = new FormData();
505
+ form.append('file', blob, basename(absPath));
506
+ if (flags.description) form.append('description', String(flags.description));
507
+ if (flags['namespace-id']) form.append('namespaceId', String(flags['namespace-id']));
508
+
509
+ let response;
510
+ try {
511
+ response = await fetch(`${endpoint}/api/memory/ingest/file`, {
512
+ method: 'POST',
513
+ headers,
514
+ body: form,
515
+ });
516
+ } catch (err) {
517
+ throw new CliError(`network error: ${err.message}`, 1);
518
+ }
519
+
520
+ // Parse NDJSON stream line-by-line.
521
+ if (!response.body) {
522
+ const text = await response.text();
523
+ throw new CliError(
524
+ `ingest-file: server returned empty body (HTTP ${response.status}): ${text.slice(0, 200)}`,
525
+ 1,
526
+ );
527
+ }
528
+
529
+ const reader = response.body.getReader();
530
+ const decoder = new TextDecoder('utf-8');
531
+ let buffer = '';
532
+ let terminalEvent = null;
533
+ let terminalError = null;
60
534
 
61
- console.log(`Copied ${fileCount} skill files to .claude/skills/pyx-memory/\n`);
62
- console.log(fileList.join('\n'));
63
- console.log('\nClaude Code will now auto-discover pyx-memory integration patterns.');
535
+ while (true) {
536
+ const { value, done } = await reader.read();
537
+ if (value) buffer += decoder.decode(value, { stream: true });
538
+ if (done) {
539
+ buffer += decoder.decode();
540
+ break;
541
+ }
542
+ let nl = buffer.indexOf('\n');
543
+ while (nl >= 0) {
544
+ const line = buffer.slice(0, nl).trim();
545
+ buffer = buffer.slice(nl + 1);
546
+ if (line) {
547
+ const event = parseNdjsonLine(line, response.status);
548
+ const handled = handleEvent(event, flags);
549
+ if (handled.terminal) {
550
+ if (event.type === 'error') terminalError = event;
551
+ else terminalEvent = event;
552
+ }
553
+ }
554
+ nl = buffer.indexOf('\n');
555
+ }
556
+ }
557
+ const tail = buffer.trim();
558
+ if (tail) {
559
+ const event = parseNdjsonLine(tail, response.status);
560
+ const handled = handleEvent(event, flags);
561
+ if (handled.terminal) {
562
+ if (event.type === 'error') terminalError = event;
563
+ else terminalEvent = event;
564
+ }
565
+ }
566
+
567
+ if (terminalError) {
568
+ const msg = terminalError.message || terminalError.error || `HTTP ${response.status}`;
569
+ throw new CliError(`ingest-file error: ${msg}`, 1);
570
+ }
571
+ if (!terminalEvent) {
572
+ throw new CliError(
573
+ `ingest-file: stream ended without a terminal result (HTTP ${response.status})`,
574
+ 1,
575
+ );
576
+ }
577
+ if (!response.ok) {
578
+ throw new CliError(`ingest-file: HTTP ${response.status} despite terminal event`, 1);
579
+ }
580
+
581
+ emit(terminalEvent, flags, (r) => {
582
+ const chunks = r.chunks ?? '?';
583
+ const fname = r.filename ?? '<file>';
584
+ return `ingested ${fname}: ${chunks} chunks`;
585
+ });
586
+ }
587
+
588
+ function parseNdjsonLine(line, status) {
589
+ try {
590
+ return JSON.parse(line);
591
+ } catch (err) {
592
+ throw new CliError(`ingest-file: malformed NDJSON line (HTTP ${status}): ${err.message}`, 1);
593
+ }
594
+ }
595
+
596
+ function handleEvent(event, flags) {
597
+ const type = event && event.type;
598
+ if (type === 'progress' || type === 'heartbeat') {
599
+ if (!flags.json) {
600
+ const stage = event.stage ?? '?';
601
+ const msg = event.message ?? '';
602
+ process.stderr.write(`[${type}:${stage}] ${msg}\n`);
603
+ }
604
+ return { terminal: false };
605
+ }
606
+ if (type === 'result' || type === 'error') {
607
+ return { terminal: true };
608
+ }
609
+ // Unknown event type — surface but don't terminate.
610
+ if (!flags.json) {
611
+ process.stderr.write(`[event] ${JSON.stringify(event)}\n`);
612
+ }
613
+ return { terminal: false };
614
+ }
615
+
616
+ // ---------- Dispatcher ----------
617
+
618
+ async function main() {
619
+ const argv = process.argv.slice(2);
620
+ const { flags, positional } = parseArgs(argv);
621
+ const command = positional.shift();
622
+
623
+ if (!command || command === '--help' || command === 'help' || (flags.help === true && !command)) {
624
+ printHelp();
625
+ return;
626
+ }
627
+
628
+ if (flags.help === true) {
629
+ printHelp(command);
630
+ return;
631
+ }
632
+
633
+ switch (command) {
634
+ case 'init':
635
+ runInit();
636
+ return;
637
+ case 'store':
638
+ await runStore(flags);
639
+ return;
640
+ case 'search':
641
+ await runSearch(flags);
642
+ return;
643
+ case 'get':
644
+ await runGet(flags, positional);
645
+ return;
646
+ case 'delete':
647
+ await runDelete(flags, positional);
648
+ return;
649
+ case 'log':
650
+ await runLog(flags);
651
+ return;
652
+ case 'summarize-entity':
653
+ await runSummarizeEntity(flags, positional);
654
+ return;
655
+ case 'ingest-file':
656
+ await runIngestFile(flags, positional);
657
+ return;
658
+ default:
659
+ process.stderr.write(`error: unknown command '${command}'\n`);
660
+ printHelp();
661
+ process.exit(2);
662
+ }
663
+ }
664
+
665
+ main().catch((err) => {
666
+ if (err instanceof CliError) {
667
+ process.stderr.write(`error: ${err.message}\n`);
668
+ process.exit(err.exitCode);
669
+ }
670
+ process.stderr.write(`error: ${err && err.stack ? err.stack : String(err)}\n`);
671
+ process.exit(1);
672
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyxmate/memory",
3
- "version": "0.20.4",
3
+ "version": "0.20.5",
4
4
  "type": "module",
5
5
  "description": "SDK for pyx-memory — Memory as a Service for AI agents",
6
6
  "license": "MIT",
@@ -37,6 +37,9 @@ Your endpoint and API key are in the project's CLAUDE.md or agent config. Look f
37
37
 
38
38
  If no credentials are configured, skip to [SDK Integration](#sdk-integration) below.
39
39
 
40
+ Operations below use the `pyxmate-memory` CLI (ships with `@pyxmate/memory`).
41
+ Set `PYXMATE_API_KEY` and `PYXMATE_ENDPOINT` once; for multi-tenant servers add `--tenant-id <id>`.
42
+
40
43
  ## Store: after important events
41
44
 
42
45
  Immediately after any of these events, store a memory before continuing:
@@ -50,23 +53,11 @@ Immediately after any of these events, store a memory before continuing:
50
53
  - **The user explicitly says "remember this"** → store immediately
51
54
 
52
55
  ```bash
53
- curl -s -X POST {{ENDPOINT}}/api/memory/ingest \
54
- -H "Authorization: Bearer {{API_KEY}}" \
55
- -H "Content-Type: application/json" \
56
- -d '{"content":"WHAT_HAPPENED","type":"long-term","metadata":{"source":"agent","topic":"TOPIC","project":"PROJECT_NAME"}}'
57
- ```
58
-
59
- **Multi-tenant mode**: When the server uses `TENANT_MODE=multi`, include `X-Tenant-Id` on all requests:
60
-
61
- ```bash
62
- curl -s -X POST {{ENDPOINT}}/api/memory/ingest \
63
- -H "Authorization: Bearer {{API_KEY}}" \
64
- -H "Content-Type: application/json" \
65
- -H "X-Tenant-Id: {{TENANT_ID}}" \
66
- -d '{"content":"WHAT_HAPPENED","type":"long-term","metadata":{"source":"agent","topic":"TOPIC","project":"PROJECT_NAME"}}'
56
+ pyxmate-memory store \
57
+ --content "WHAT_HAPPENED" --topic TOPIC --project PROJECT_NAME --source agent
67
58
  ```
68
59
 
69
- When the memory mentions specific people, tools, organizations, or other named subjects, also include `entities` and `relationships` to populate the knowledge graph. See [reference/http-api.md](reference/http-api.md) for entity types, relationship types, and examples.
60
+ When the memory mentions named people, tools, or organizations, pass `--entities '<json>'` and `--relationships '<json>'` (or `@path.json` / `-` for stdin) to populate the knowledge graph. See [reference/http-api.md](reference/http-api.md) for entity and relationship types.
70
61
 
71
62
  ## Search: before making assumptions
72
63
 
@@ -78,12 +69,9 @@ Search for relevant context in these situations:
78
69
  - **Investigating a bug** → search if it was seen before
79
70
 
80
71
  ```bash
81
- curl -s "{{ENDPOINT}}/api/memory/search?q=QUERY&limit=5" \
82
- -H "Authorization: Bearer {{API_KEY}}"
72
+ pyxmate-memory search --query "QUERY" --limit 5
83
73
  ```
84
74
 
85
- **Multi-tenant mode**: Add `-H "X-Tenant-Id: {{TENANT_ID}}"` to scope search results to the tenant.
86
-
87
75
  ## Ingest files & images: when a file is worth remembering
88
76
 
89
77
  Upload files directly when the content is worth persisting — diagrams, screenshots, documents, data files.
@@ -93,10 +81,7 @@ Upload files directly when the content is worth persisting — diagrams, screens
93
81
  - **A document contains reference material** (spec, config, report) → ingest it
94
82
 
95
83
  ```bash
96
- curl -s -X POST {{ENDPOINT}}/api/memory/ingest/file \
97
- -H "Authorization: Bearer {{API_KEY}}" \
98
- -F "file=@path/to/file" \
99
- -F "description=What this file contains or shows"
84
+ pyxmate-memory ingest-file path/to/file --description "What this file contains or shows"
100
85
  ```
101
86
 
102
87
  Supported formats: txt, md, csv, tsv, log, pdf, docx, xlsx, pptx, json, jsonl, html, htm, png, jpg, jpeg, webp, gif, bmp, tiff, svg (100MB limit).