quoxcli 0.4.0 → 0.6.0

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.
Files changed (199) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * QuoxMCP setup from the CLI (VULCAN): the CLI rung under Settings > MCP credentials.
3
+ *
4
+ * quox mcp token create --label <text> [--days <1-90>] mint a personal MCP token (raw token shown ONCE)
5
+ * quox mcp token list your MCP tokens (never the token or its hash)
6
+ * quox mcp token revoke <id> revoke one of your tokens
7
+ * quox mcp bundle [--out <file>] personalised Claude Desktop .mcpb (EMBEDS a fresh token)
8
+ *
9
+ * Tokens: auth /api/auth/tokens/mcp (services/auth/routes/mcp-tokens.js). Bundle: collector /api/v1/mcp/bundle
10
+ * (services/collector/routes/mcp-bundle.js), which takes the raw token in the body and bakes it into the
11
+ * bundle manifest as QUOX_SERVICE_KEY, so the bundle file is a credential: written 0600, never printed.
12
+ */
13
+
14
+ import { existsSync, writeFileSync, chmodSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import { formatOutput, printSuccess, printInfo, printWarning } from '../core/output.js';
17
+ import { resolveEndpoint } from '../api/endpoints.js';
18
+ import { QuoxError } from '../core/errors.js';
19
+
20
+ const MAX_DAYS = 90;
21
+ const OUTPUT_FORMATS = ['table', 'json', 'csv', 'quiet'];
22
+ const DEFAULT_BUNDLE = 'quoxmcp.mcpb';
23
+
24
+ function parseDays(raw) {
25
+ if (raw === undefined) return undefined;
26
+ const s = String(raw).trim();
27
+ const n = Number(s);
28
+ if (!/^\d+$/.test(s) || n < 1 || n > MAX_DAYS) {
29
+ throw new QuoxError(`--days must be a whole number from 1 to ${MAX_DAYS} (got "${raw}")`, 1);
30
+ }
31
+ return n;
32
+ }
33
+
34
+ function parseLabel(raw) {
35
+ const label = String(raw ?? '').trim();
36
+ if (!label) throw new QuoxError('--label must not be empty', 1);
37
+ if (label.length > 128) throw new QuoxError('--label must be 128 characters or fewer', 1);
38
+ return label;
39
+ }
40
+
41
+ const isJson = (program) => program.opts().output === 'json';
42
+
43
+ export function registerMcpCommands(program, ctx) {
44
+ const { client, auth, config } = ctx;
45
+
46
+ async function mint(label, days) {
47
+ const ep = resolveEndpoint('mcp.tokens.create');
48
+ return client.post(ep.path, {
49
+ service: ep.service,
50
+ sensitive: true,
51
+ body: { label, ...(days !== undefined ? { expires_in_days: days } : {}) },
52
+ });
53
+ }
54
+
55
+ const mcp = program
56
+ .command('mcp')
57
+ .description('QuoxMCP setup: personal MCP tokens and the Claude Desktop bundle');
58
+
59
+ const token = mcp
60
+ .command('token')
61
+ .description('Personal MCP tokens (qmcp_...) for connecting an MCP client to Quox');
62
+
63
+ token
64
+ .command('create')
65
+ .description(`Mint a personal MCP token for your active org; the raw token is shown ONCE (expires in ${MAX_DAYS} days unless --days is lower)`)
66
+ .requiredOption('--label <text>', 'what this token is for, e.g. "work laptop" (max 128 chars)')
67
+ .option('--days <n>', `days until it expires, 1 to ${MAX_DAYS} (default ${MAX_DAYS})`)
68
+ .action(async (opts) => {
69
+ auth.requireAuth();
70
+ const label = parseLabel(opts.label);
71
+ const days = parseDays(opts.days);
72
+ const data = await mint(label, days);
73
+ if (isJson(program)) {
74
+ console.log(JSON.stringify(data, null, 2));
75
+ return;
76
+ }
77
+ printSuccess(`MCP token ${data.id} created (label "${data.label ?? label}").`);
78
+ console.log('Token (shown once, store it now; it cannot be shown again):');
79
+ console.log(data.token);
80
+ console.log(`Expires: ${data.expires_at || 'no expiry'}`);
81
+ printInfo(`Revoke it any time with: quox mcp token revoke ${data.id}`);
82
+ });
83
+
84
+ token
85
+ .command('list')
86
+ .description('List your MCP tokens (id, label, created, last used, expiry, revoked); never shows a token value')
87
+ .action(async () => {
88
+ auth.requireAuth();
89
+ const ep = resolveEndpoint('mcp.tokens.list');
90
+ const data = await client.get(ep.path, { service: ep.service });
91
+ const tokens = Array.isArray(data?.tokens) ? data.tokens : [];
92
+ formatOutput({
93
+ columns: ['id', 'label', 'created_at', 'last_used_at', 'expires_at', 'revoked_at'],
94
+ headers: ['ID', 'Label', 'Created', 'Last used', 'Expires', 'Revoked'],
95
+ rows: tokens.map((t) => ({
96
+ id: t.id,
97
+ label: t.label,
98
+ created_at: t.created_at || '',
99
+ last_used_at: t.last_used_at || 'never',
100
+ expires_at: t.expires_at || 'no expiry',
101
+ revoked_at: t.revoked_at || 'no',
102
+ })),
103
+ }, { format: program.opts().output, raw: tokens, emptyMessage: 'No MCP tokens. Create one with: quox mcp token create --label <text>' });
104
+ });
105
+
106
+ token
107
+ .command('revoke')
108
+ .description('Revoke one of your MCP tokens (takes effect at the collector straight away)')
109
+ .argument('<id>', 'token id from `quox mcp token list`')
110
+ .action(async (id) => {
111
+ auth.requireAuth();
112
+ const ep = resolveEndpoint('mcp.tokens.revoke', { id });
113
+ try {
114
+ await client.delete(ep.path, { service: ep.service });
115
+ } catch (err) {
116
+ if (err?.status === 404) throw new QuoxError(`MCP token ${id} not found or already revoked.`, 1);
117
+ throw err;
118
+ }
119
+ printSuccess(`MCP token ${id} revoked.`);
120
+ });
121
+
122
+ mcp
123
+ .command('bundle')
124
+ .description('Download a personalised QuoxMCP bundle (.mcpb) for Claude Desktop. It mints a NEW MCP token and embeds a live MCP token in the file: treat the file as a secret (written 0600, never printed)')
125
+ .option('--out <path>', `where to save it (-o <path> also works); default ./${DEFAULT_BUNDLE}`)
126
+ .option('--label <text>', 'label for the token minted into the bundle', 'Claude Desktop')
127
+ .option('--days <n>', `days until the embedded token expires, 1 to ${MAX_DAYS} (default ${MAX_DAYS})`)
128
+ .option('--collector-url <url>', 'collector URL the bundle will call (default: this profile\'s collector URL)')
129
+ .option('--force', 'overwrite an existing file')
130
+ .addHelpText('after', '\nThe bundle embeds a live MCP token. Anyone holding the file can act as you over MCP until the token\nexpires or you revoke it (quox mcp token revoke <id>). Do not commit or share it.\n')
131
+ .action(async (opts) => {
132
+ auth.requireAuth();
133
+ const label = parseLabel(opts.label);
134
+ const days = parseDays(opts.days);
135
+ const collectorUrl = opts.collectorUrl || config?.getServiceUrl?.('collector');
136
+ try {
137
+ if (!collectorUrl) throw new Error('missing');
138
+ new URL(collectorUrl);
139
+ } catch {
140
+ throw new QuoxError(`--collector-url must be a valid URL (got "${collectorUrl ?? ''}")`, 1);
141
+ }
142
+ const g = program.opts().output;
143
+ const outArg = opts.out || (g && !OUTPUT_FORMATS.includes(g) ? g : undefined) || DEFAULT_BUNDLE;
144
+ const target = resolve(ctx.cwd || process.cwd(), outArg);
145
+ if (existsSync(target) && !opts.force) throw new QuoxError(`${target} already exists. Use --force to overwrite.`, 1);
146
+
147
+ const minted = await mint(label, days);
148
+ const revokeMinted = async () => {
149
+ try {
150
+ const rep = resolveEndpoint('mcp.tokens.revoke', { id: minted.id });
151
+ await client.delete(rep.path, { service: rep.service });
152
+ printWarning(`Revoked the token minted for this bundle (${minted.id}).`);
153
+ } catch {
154
+ printWarning(`Could not revoke the token minted for this bundle; revoke it with: quox mcp token revoke ${minted.id}`);
155
+ }
156
+ };
157
+
158
+ const bep = resolveEndpoint('mcp.bundle');
159
+ let buf;
160
+ try {
161
+ const res = await client.post(bep.path, {
162
+ service: bep.service,
163
+ raw: true,
164
+ sensitive: true,
165
+ timeout: 120000,
166
+ body: { token: minted.token, collector_url: collectorUrl, agent_id: 'quox' },
167
+ });
168
+ if (!res.ok) {
169
+ let detail = '';
170
+ try { const b = await res.json(); detail = b?.message || b?.error || ''; } catch { /* non-JSON body */ }
171
+ throw new QuoxError(`Bundle generation failed: HTTP ${res.status}${detail ? ` (${detail})` : ''}.`, 1);
172
+ }
173
+ buf = Buffer.from(await res.arrayBuffer());
174
+ } catch (err) {
175
+ await revokeMinted();
176
+ throw err;
177
+ }
178
+
179
+ writeFileSync(target, buf, { mode: 0o600 });
180
+ chmodSync(target, 0o600); // mode only applies on create; --force over an existing file must tighten it too
181
+ if (isJson(program)) {
182
+ console.log(JSON.stringify({ path: target, bytes: buf.length, token_id: minted.id, expires_at: minted.expires_at ?? null, collector_url: collectorUrl }, null, 2));
183
+ return;
184
+ }
185
+ printSuccess(`Saved ${target} (${buf.length} bytes, mode 0600).`);
186
+ console.log(`It embeds MCP token ${minted.id} (expires ${minted.expires_at || 'never'}) and calls ${collectorUrl}.`);
187
+ console.log('Open the file with Claude Desktop to install QuoxMCP. Treat it as a secret.');
188
+ printInfo(`Revoke it any time with: quox mcp token revoke ${minted.id}`);
189
+ });
190
+ }
@@ -27,12 +27,18 @@ import { QuoxError } from '../core/errors.js';
27
27
  import { resolveOrgId } from './helpers.js';
28
28
  import { resolveMyAttach } from './agentAttach.js';
29
29
  import { hasExplicitProfile } from '../core/actingAs.js';
30
+ import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
31
+ import { syncMemoryFiles, expandPaths, defaultMemoryDir } from './memorySync.js';
32
+ import { announce, announceFailure } from '../core/announce.js';
30
33
 
31
34
  // QUOXIDENTITY P9: which agent this session acts as is resolved session-scoped
32
35
  // (QUOX_T subtree / QUOX_ATTACH_AGENT override / single-attach fallback), so
33
36
  // two sessions on one box no longer collide. See agentAttach.js resolveMyAttach.
34
37
  const detectSoleAttachLease = resolveMyAttach;
35
38
 
39
+ // Short, single-line form of a memory's text for the announce line.
40
+ const clip = (t) => { const x = String(t || '').replace(/\s+/g, ' ').trim(); return x.length > 28 ? x.slice(0, 27) + '…' : x; };
41
+
36
42
  /**
37
43
  * Call a governed collector memory tool (POST /api/v1/tools/execute).
38
44
  * Lease-aware: when exactly one attach lease is active, the call is made
@@ -161,6 +167,7 @@ export function registerMemoryCommands(program, ctx) {
161
167
  auth.requireAuth();
162
168
  const ep = resolveEndpoint('memory.reinstate', { id });
163
169
  const data = await client.post(ep.path, { service: ep.service, body: {} });
170
+ announce('memory', `reinstated ${data.reinstated?.id || id}`);
164
171
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
165
172
  printSuccess(`Reinstated ${data.reinstated?.id}; retired ${data.retired?.id} (marked disputed, Dream will not re-apply it)`);
166
173
  });
@@ -180,10 +187,56 @@ export function registerMemoryCommands(program, ctx) {
180
187
  };
181
188
  if (options.tags) body.tags = options.tags.split(',').map(t => t.trim());
182
189
  if (options.agent) body.agent_id = options.agent;
183
- await client.post(resolveEndpoint('memory.create').path, { service: resolveEndpoint('memory.create').service, body });
190
+ const label = `${options.type} "${clip(options.content)}"`;
191
+ let data;
192
+ try {
193
+ data = await client.post(resolveEndpoint('memory.create').path, { service: resolveEndpoint('memory.create').service, body });
194
+ } catch (err) { announceFailure('memory', `save ${label}`, err); throw err; }
195
+ announce('memory', `saved ${label}`, { result: data?.id || data?.memory?.id });
184
196
  printSuccess('Memory saved.');
185
197
  });
186
198
 
199
+ // Owner order 2026-10-29: do not replace Claude's file memory, ALSO save it to Quox memory.
200
+ // Additive and idempotent (see memorySync.js). One QuoxGlyph line per file saved or updated.
201
+ memory
202
+ .command('sync')
203
+ .description("Also save Claude Code's file memory (default: this project's memory dir) into Quox memory. Idempotent, additive, secrets refused")
204
+ .argument('[paths...]', 'memory files or directories (default: ~/.claude/projects/<this cwd>/memory)')
205
+ .option('--dry-run', 'show what would be saved without writing')
206
+ .action(async (paths, options) => {
207
+ auth.requireAuth();
208
+ const json = program.opts().output === 'json';
209
+ const targets = paths && paths.length ? paths : [defaultMemoryDir()];
210
+ const files = expandPaths(targets);
211
+ const ep = resolveEndpoint('memory.create');
212
+ const orgId = auth.getOrgId?.(program.opts().org) || 'default';
213
+ const results = await syncMemoryFiles({ client, orgId, files, dryRun: !!options.dryRun, memoryEp: ep });
214
+ const count = (a) => results.filter((r) => r.action === a).length;
215
+ const summary = {
216
+ created: count('created'), updated: count('updated'), unchanged: count('unchanged'),
217
+ refused: count('refused'), errors: count('error') + count('missing'),
218
+ would_create: count('would-create'), would_update: count('would-update'),
219
+ };
220
+ if (json) {
221
+ console.log(JSON.stringify({ dry_run: !!options.dryRun, summary, results }, null, 2));
222
+ } else {
223
+ const q = (r) => `"${r.name}"`;
224
+ for (const r of results) {
225
+ if (r.action === 'created') printEvent(glyphLine('memory', `also saved to Quox ${q(r)}`, { status: 'ok' }));
226
+ else if (r.action === 'updated') printEvent(glyphLine('memory', `updated in Quox ${q(r)}`, { status: 'ok' }));
227
+ else if (r.action === 'would-create') printEvent(glyphLine('memory', `would save to Quox ${q(r)}`, { status: 'busy' }));
228
+ else if (r.action === 'would-update') printEvent(glyphLine('memory', `would update in Quox ${q(r)}`, { status: 'busy' }));
229
+ else if (r.action === 'refused') printEvent(glyphLine('memory', `refused ${r.file.split('/').pop()}`, { result: `secret shape: ${r.kinds.join(', ')}`, status: 'warn' }));
230
+ else if (r.action === 'error' || r.action === 'missing') printEvent(glyphLine('memory', `sync ${r.file.split('/').pop()}`, { result: r.action === 'missing' ? 'file not found' : (r.error || 'failed').slice(0, 40), status: 'fail' }));
231
+ }
232
+ const bits = options.dryRun
233
+ ? `${summary.would_create} to save, ${summary.would_update} to update, ${summary.unchanged} unchanged`
234
+ : `${summary.created} saved, ${summary.updated} updated, ${summary.unchanged} unchanged`;
235
+ console.log(`Memory sync: ${bits}${summary.refused ? `, ${summary.refused} refused (secret shapes)` : ''}${summary.errors ? `, ${summary.errors} failed` : ''}.`);
236
+ }
237
+ if (summary.errors) process.exitCode = 1;
238
+ });
239
+
187
240
  memory
188
241
  .command('update')
189
242
  .description('Update a memory')
@@ -195,7 +248,10 @@ export function registerMemoryCommands(program, ctx) {
195
248
  const body = {};
196
249
  if (options.content) body.content = options.content;
197
250
  if (options.tags) body.tags = options.tags.split(',').map(t => t.trim());
198
- await client.patch(resolveEndpoint('memory.update', { id }).path, { service: resolveEndpoint('memory.update', { id }).service, body });
251
+ try {
252
+ await client.patch(resolveEndpoint('memory.update', { id }).path, { service: resolveEndpoint('memory.update', { id }).service, body });
253
+ } catch (err) { announceFailure('memory', `update ${id}`, err); throw err; }
254
+ announce('memory', `updated ${id}`, { result: options.content ? `"${clip(options.content)}"` : undefined });
199
255
  printSuccess('Memory updated.');
200
256
  });
201
257
 
@@ -208,7 +264,10 @@ export function registerMemoryCommands(program, ctx) {
208
264
  auth.requireAuth();
209
265
  const query = {};
210
266
  if (options.hard) query.hard = 'true';
211
- await client.delete(resolveEndpoint('memory.delete', { id }).path, { service: resolveEndpoint('memory.delete', { id }).service, query });
267
+ try {
268
+ await client.delete(resolveEndpoint('memory.delete', { id }).path, { service: resolveEndpoint('memory.delete', { id }).service, query });
269
+ } catch (err) { announceFailure('memory', `delete ${id}`, err); throw err; }
270
+ announce('memory', `deleted ${id}`, { result: options.hard ? 'permanent' : 'soft delete' });
212
271
  printSuccess('Memory deleted.');
213
272
  });
214
273
 
@@ -226,7 +285,10 @@ export function registerMemoryCommands(program, ctx) {
226
285
  const ep = resolveEndpoint('memory.delete', { id });
227
286
  const query = {};
228
287
  if (options.permanent) query.hard = 'true';
229
- await client.delete(ep.path, { service: ep.service, query });
288
+ try {
289
+ await client.delete(ep.path, { service: ep.service, query });
290
+ } catch (err) { announceFailure('memory', `forget ${id}`, err); throw err; }
291
+ announce('memory', `forgot ${id}`, { result: options.permanent ? 'erased for good' : 'soft delete' });
230
292
  printSuccess(options.permanent ? `Permanently erased memory ${id}.` : `Forgot memory ${id}.`);
231
293
  });
232
294
 
@@ -333,6 +395,7 @@ export function registerMemoryCommands(program, ctx) {
333
395
  auth.requireAuth();
334
396
  const ep = resolveEndpoint('memory.pin', { id });
335
397
  const data = await client.post(ep.path, { service: ep.service, body: {} });
398
+ announce('memory', `pinned ${id}`, { result: 'core set' });
336
399
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
337
400
  printSuccess(`Pinned ${id} into the core set.`);
338
401
  });
@@ -345,6 +408,7 @@ export function registerMemoryCommands(program, ctx) {
345
408
  auth.requireAuth();
346
409
  const ep = resolveEndpoint('memory.unpin', { id });
347
410
  const data = await client.delete(ep.path, { service: ep.service });
411
+ announce('memory', `unpinned ${id}`, { result: 'out of core set' });
348
412
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
349
413
  printSuccess(`Unpinned ${id}: it stays a memory, but is no longer in the core set.`);
350
414
  });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `quox memory sync`: also save agent file memory (Claude Code's
3
+ * ~/.claude/projects/<slug>/memory/*.md) into Quox memory. ADDITIVE: the Claude
4
+ * files are only read, never written, and a removed file never deletes a Quox
5
+ * memory. Idempotent: each file has a stable source key
6
+ * (claude-memory:<slug>/<file>) and a content hash; unchanged files are skipped,
7
+ * changed files PATCH the same Quox memory. Files containing secret shapes are
8
+ * refused (named, never echoed).
9
+ */
10
+ import { createHash } from 'node:crypto';
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, renameSync } from 'node:fs';
12
+ import { basename, dirname, join, resolve } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { secretKinds } from '../quoxon/secretShape.js';
15
+
16
+ const MAX_CONTENT = 20000;
17
+
18
+ /** Claude memory type -> closest Quox memory type (the API accepts fact, preference, decision, observation, entity). */
19
+ export function quoxTypeFor(claudeType) {
20
+ switch (String(claudeType || '').toLowerCase()) {
21
+ case 'user': return 'preference';
22
+ case 'feedback': return 'decision';
23
+ case 'project': return 'fact';
24
+ case 'reference': return 'fact';
25
+ default: return 'observation';
26
+ }
27
+ }
28
+
29
+ const unquote = (v) => String(v ?? '').trim().replace(/^(["'])(.*)\1$/, '$2');
30
+
31
+ /** -> { name, description, type, body } from a Claude memory file (frontmatter optional). */
32
+ export function parseMemoryFile(text, fallbackName = '') {
33
+ const src = String(text ?? '').replace(/\r\n/g, '\n');
34
+ let front = '';
35
+ let body = src;
36
+ const m = src.match(/^---\n([\s\S]*?)\n---\n?/);
37
+ if (m) { front = m[1]; body = src.slice(m[0].length); }
38
+ const out = { name: '', description: '', type: '' };
39
+ let inMeta = false;
40
+ for (const raw of front.split('\n')) {
41
+ const top = raw.match(/^([A-Za-z_]+):\s*(.*)$/);
42
+ if (top) {
43
+ inMeta = top[1] === 'metadata';
44
+ if (top[1] === 'name') out.name = unquote(top[2]);
45
+ else if (top[1] === 'description') out.description = unquote(top[2]);
46
+ else if (top[1] === 'type') out.type = unquote(top[2]);
47
+ continue;
48
+ }
49
+ const nested = raw.match(/^\s+type:\s*(.*)$/);
50
+ if (inMeta && nested) out.type = unquote(nested[1]);
51
+ }
52
+ return { name: out.name || fallbackName, description: out.description, type: out.type, body: body.trim() };
53
+ }
54
+
55
+ /** Stable key: claude-memory:<project slug>/<file>. */
56
+ export function sourceKeyFor(file) {
57
+ const abs = resolve(file);
58
+ const parts = abs.split('/');
59
+ const mi = parts.lastIndexOf('memory');
60
+ const slug = mi > 0 ? parts[mi - 1] : createHash('sha256').update(dirname(abs)).digest('hex').slice(0, 8);
61
+ const rel = mi >= 0 ? parts.slice(mi + 1).join('/') : basename(abs);
62
+ return `claude-memory:${slug}/${rel}`;
63
+ }
64
+
65
+ /** Default memory dir for a cwd: ~/.claude/projects/<cwd with non-alnum -> '-'>/memory. */
66
+ export function defaultMemoryDir(cwd = process.cwd()) {
67
+ return join(homedir(), '.claude', 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), 'memory');
68
+ }
69
+
70
+ /** Expand dirs to their *.md files; MEMORY.md (the index) is skipped. */
71
+ export function expandPaths(paths) {
72
+ const files = [];
73
+ for (const p of paths) {
74
+ const abs = resolve(p);
75
+ if (!existsSync(abs)) { files.push({ file: abs, missing: true }); continue; }
76
+ if (statSync(abs).isDirectory()) {
77
+ for (const f of readdirSync(abs).sort()) if (f.endsWith('.md')) files.push({ file: join(abs, f) });
78
+ } else files.push({ file: abs });
79
+ }
80
+ return files.filter((f) => f.missing || basename(f.file) !== 'MEMORY.md');
81
+ }
82
+
83
+ const statePathDefault = () => join(homedir(), '.quox', 'memory-sync-state.json');
84
+ function loadState(p) { try { return JSON.parse(readFileSync(p, 'utf8')) || {}; } catch { return {}; } }
85
+ function saveState(p, state) {
86
+ mkdirSync(dirname(p), { recursive: true });
87
+ const tmp = `${p}.${process.pid}.tmp`;
88
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
89
+ renameSync(tmp, p);
90
+ }
91
+
92
+ /**
93
+ * Sync files into Quox memory. client = the CLI http client; post/patch/get go to
94
+ * the memory service paths. Returns [{ file, key, name, action, id?, kinds?, error? }]
95
+ * where action is created | updated | unchanged | refused | missing | error | would-create | would-update.
96
+ */
97
+ export async function syncMemoryFiles({ client, orgId = 'default', files, dryRun = false, statePath = statePathDefault(), memoryEp }) {
98
+ const state = loadState(statePath);
99
+ const mine = (state[orgId] ||= {});
100
+ let remote; // lazily loaded recovery index: source_key -> {id, hash}
101
+ const recover = async (key) => {
102
+ if (!remote) {
103
+ remote = {};
104
+ try {
105
+ const data = await client.get(memoryEp.path, { service: memoryEp.service, query: { limit: 1000 } });
106
+ for (const m of (data?.memories || [])) {
107
+ const sk = m?.metadata?.source_key;
108
+ if (sk) remote[sk] = { id: m.id, hash: m.metadata.content_hash };
109
+ }
110
+ } catch { /* recovery is best effort; worst case a first-time key creates */ }
111
+ }
112
+ return remote[key];
113
+ };
114
+
115
+ const results = [];
116
+ for (const entry of files) {
117
+ const { file } = entry;
118
+ const key = sourceKeyFor(file);
119
+ if (entry.missing) { results.push({ file, key, name: basename(file), action: 'missing' }); continue; }
120
+ let text;
121
+ try { text = readFileSync(file, 'utf8'); } catch (e) { results.push({ file, key, name: basename(file), action: 'error', error: e.message }); continue; }
122
+ const parsed = parseMemoryFile(text, basename(file, '.md'));
123
+ const kinds = secretKinds(text, { precise: true });
124
+ if (kinds.length) { results.push({ file, key, name: parsed.name, action: 'refused', rule: kinds[0], kinds }); continue; }
125
+ let content = [parsed.name, parsed.description].filter(Boolean).join(': ');
126
+ content = (content ? content + '\n\n' : '') + parsed.body;
127
+ content = content.slice(0, MAX_CONTENT);
128
+ const hash = createHash('sha256').update(content + '\0' + parsed.type).digest('hex');
129
+ const known = mine[key] || await recover(key);
130
+ if (known && known.hash === hash) { mine[key] = { id: known.id, hash }; results.push({ file, key, name: parsed.name, action: 'unchanged', id: known.id }); continue; }
131
+ const metadata = { source: 'claude-memory', source_key: key, content_hash: hash, source_file: basename(file), claude_type: parsed.type || null };
132
+ const tags = ['claude-memory', ...(parsed.type ? [`claude-memory:${parsed.type}`] : [])];
133
+ if (dryRun) { results.push({ file, key, name: parsed.name, action: known ? 'would-update' : 'would-create', id: known?.id }); continue; }
134
+ try {
135
+ let id = known?.id;
136
+ let action = 'created';
137
+ if (id) {
138
+ try {
139
+ await client.patch(`${memoryEp.path}/${id}`, { service: memoryEp.service, body: { content, tags, metadata } });
140
+ action = 'updated';
141
+ } catch (e) {
142
+ if (e?.status !== 404) throw e;
143
+ id = null; // the Quox memory was removed; make a fresh one
144
+ }
145
+ }
146
+ if (!id) {
147
+ const created = await client.post(memoryEp.path, { service: memoryEp.service, body: { type: quoxTypeFor(parsed.type), content, tags, metadata } });
148
+ id = created?.id || created?.memory?.id || null;
149
+ }
150
+ if (id) mine[key] = { id, hash };
151
+ results.push({ file, key, name: parsed.name, action, id });
152
+ } catch (e) {
153
+ results.push({ file, key, name: parsed.name, action: 'error', error: e?.message || String(e) });
154
+ }
155
+ }
156
+ if (!dryRun) { try { saveState(statePath, state); } catch { /* state is a cache; recovery covers it */ } }
157
+ return results;
158
+ }