memorix 1.5.1 → 1.6.1

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 (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/dist/cli/index.js +558 -102
  5. package/dist/cli/memcode.js +12 -1
  6. package/dist/index.js +221 -27
  7. package/dist/maintenance-runner.js +59 -11
  8. package/dist/media-audio-runner.js +59 -11
  9. package/dist/media-video-runner.js +59 -11
  10. package/dist/memcode-runtime/CHANGELOG.md +47 -0
  11. package/dist/sdk.js +220 -26
  12. package/dist/vector-backfill-runner.js +59 -11
  13. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +1 -1
  14. package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
  15. package/docs/API_REFERENCE.md +2 -2
  16. package/docs/DEVELOPMENT.md +5 -3
  17. package/docs/DOCKER.md +5 -1
  18. package/docs/PERFORMANCE.md +2 -2
  19. package/docs/README.md +3 -1
  20. package/docs/SETUP.md +8 -2
  21. package/package.json +1 -1
  22. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  23. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  24. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/copilot/memorix/plugin.json +1 -1
  26. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  27. package/plugins/omp/memorix/package.json +1 -1
  28. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  29. package/plugins/pi/memorix/package.json +1 -1
  30. package/server.json +2 -2
  31. package/src/cli/commands/agent-integrations.ts +9 -3
  32. package/src/cli/commands/codegraph.ts +48 -5
  33. package/src/cli/commands/context.ts +20 -4
  34. package/src/cli/commands/doctor.ts +46 -15
  35. package/src/cli/commands/hook.ts +13 -1
  36. package/src/cli/commands/resume.ts +4 -0
  37. package/src/cli/commands/serve-http.ts +68 -7
  38. package/src/cli/heap.ts +76 -0
  39. package/src/cli/invocation.ts +7 -4
  40. package/src/codegraph/auto-context.ts +4 -1
  41. package/src/codegraph/context-pack.ts +3 -1
  42. package/src/codegraph/external-provider.ts +93 -0
  43. package/src/embedding/provider.ts +76 -6
  44. package/src/hooks/handler.ts +9 -18
  45. package/src/hooks/installers/index.ts +1 -1
  46. package/src/hooks/lifecycle.ts +173 -0
  47. package/src/hooks/official-skills.ts +3 -3
  48. package/src/hooks/rules/memorix-agent-rules.md +1 -1
  49. package/src/knowledge/context-receipt.ts +41 -0
  50. package/src/knowledge/workflows.ts +6 -3
  51. package/src/knowledge/workset.ts +56 -2
  52. package/src/server.ts +15 -3
@@ -7,6 +7,9 @@ import {
7
7
  } from '../../codegraph/auto-context.js';
8
8
  import { getAllObservations } from '../../memory/observations.js';
9
9
  import { filterReadableObservations } from '../../memory/visibility.js';
10
+ import { buildBoundedContextReceipt } from '../../knowledge/context-receipt.js';
11
+ import { WORKFLOW_AGENT_TARGETS } from '../../knowledge/workflows.js';
12
+ import type { AgentTarget } from '../../types.js';
10
13
  import { emitError, emitResult, getCliProjectContext } from './operator-shared.js';
11
14
 
12
15
  function coerceRefreshMode(input?: string): AutoContextRefreshMode {
@@ -15,6 +18,13 @@ function coerceRefreshMode(input?: string): AutoContextRefreshMode {
15
18
  throw new Error('refresh must be one of: auto, always, never');
16
19
  }
17
20
 
21
+ function coerceAgentTarget(input?: string): AgentTarget | undefined {
22
+ const value = input?.trim().toLowerCase();
23
+ if (!value) return undefined;
24
+ if (WORKFLOW_AGENT_TARGETS.includes(value as AgentTarget)) return value as AgentTarget;
25
+ throw new Error('agent must be one of: ' + WORKFLOW_AGENT_TARGETS.join(', '));
26
+ }
27
+
18
28
  export default defineCommand({
19
29
  meta: {
20
30
  name: 'context',
@@ -29,10 +39,12 @@ export default defineCommand({
29
39
  },
30
40
  resume: { type: 'boolean', description: 'Always include the bounded prior-work projection' },
31
41
  refresh: { type: 'string', description: 'Project scan policy: auto, always, or never' },
42
+ agent: { type: 'string', description: 'Optional target agent for compatible workflow selection' },
32
43
  json: { type: 'boolean', description: 'Emit machine-readable JSON output' },
44
+ briefJson: { type: 'boolean', description: 'Emit only the bounded agent brief and receipt JSON' },
33
45
  },
34
46
  run: async ({ args }) => {
35
- const asJson = !!args.json;
47
+ const asJson = !!args.json || !!args.briefJson;
36
48
  const task = (args.task as string | undefined)?.trim()
37
49
  || (args.input as string | undefined)?.trim()
38
50
  || undefined;
@@ -44,13 +56,13 @@ export default defineCommand({
44
56
  dataDir,
45
57
  observations: filterReadableObservations(getAllObservations(), reader),
46
58
  task,
59
+ agent: coerceAgentTarget(args.agent as string | undefined),
47
60
  refresh: coerceRefreshMode(args.refresh as string | undefined),
48
61
  reader,
49
62
  ...(args.resume ? { continuation: 'always' as const } : {}),
50
63
  });
51
64
 
52
- emitResult(
53
- {
65
+ const detailed = {
54
66
  project,
55
67
  lens: context.lens,
56
68
  brief: buildAutoProjectBrief(context),
@@ -61,7 +73,11 @@ export default defineCommand({
61
73
  workset: context.workset,
62
74
  ...(context.task ? { task: context.task } : {}),
63
75
  ...(context.continuation ? { continuation: context.continuation } : {}),
64
- },
76
+ };
77
+ emitResult(
78
+ args.briefJson
79
+ ? buildBoundedContextReceipt({ workset: context.workset, providerQuality: context.providerQuality })
80
+ : detailed,
65
81
  formatAutoProjectContextPrompt(context),
66
82
  asJson,
67
83
  );
@@ -131,6 +131,7 @@ export default defineCommand({
131
131
  let bgRunning = false;
132
132
  let bgPort = 0;
133
133
  let bgPid = 0;
134
+ let backgroundHealthy = false;
134
135
 
135
136
  try {
136
137
  if (existsSync(bgStatePath)) {
@@ -148,7 +149,7 @@ export default defineCommand({
148
149
  // Health check
149
150
  try {
150
151
  const http = await import('node:http');
151
- const healthy = await new Promise<boolean>((resolve) => {
152
+ backgroundHealthy = await new Promise<boolean>((resolve) => {
152
153
  const req = http.request({ hostname: '127.0.0.1', port: bgPort, path: '/api/team', timeout: 3000 }, (res) => {
153
154
  res.resume();
154
155
  resolve(res.statusCode === 200);
@@ -157,7 +158,7 @@ export default defineCommand({
157
158
  req.on('timeout', () => { req.destroy(); resolve(false); });
158
159
  req.end();
159
160
  });
160
- if (healthy) {
161
+ if (backgroundHealthy) {
161
162
  lines.push(ok('Health check: OK'));
162
163
  } else {
163
164
  lines.push(warn('Health check: FAILED (process alive but not responding)'));
@@ -165,8 +166,9 @@ export default defineCommand({
165
166
  }
166
167
  } catch {
167
168
  lines.push(warn('Health check: could not connect'));
169
+ issues.push('Background process is alive but its health endpoint could not be reached. Try "memorix background restart".');
168
170
  }
169
- report.mode = { type: 'background', pid: bgPid, port: bgPort, healthy: true };
171
+ report.mode = { type: 'background', pid: bgPid, port: bgPort, healthy: backgroundHealthy };
170
172
  } else {
171
173
  lines.push(info('Background control plane: not running'));
172
174
  lines.push(info('Current invocation: CLI (stdio)'));
@@ -210,20 +212,49 @@ export default defineCommand({
210
212
  } else {
211
213
  lines.push(ok(`Mode: ${mode}`));
212
214
 
213
- // Try to initialize provider to check real status
214
- const { getEmbeddingProvider } = await import('../../embedding/provider.js');
215
- const provider = await getEmbeddingProvider();
215
+ // A running control plane is the source of truth for runtime health.
216
+ // A fresh doctor process must not overwrite a live 401/402/timeout
217
+ // diagnosis with a static "configured = ready" result.
218
+ let controlPlaneEmbedding: {
219
+ status?: string;
220
+ provider?: { name?: string; dimensions?: number };
221
+ failure?: { kind?: string; retryAfterMs?: number };
222
+ } | undefined;
223
+ if (bgRunning) {
224
+ try {
225
+ const response = await fetch(`http://127.0.0.1:${bgPort}/health`, { signal: AbortSignal.timeout(3_000) });
226
+ if (response.ok) {
227
+ const health = await response.json() as { embedding?: typeof controlPlaneEmbedding };
228
+ controlPlaneEmbedding = health.embedding;
229
+ }
230
+ } catch { /* health is advisory; fall back to a local probe */ }
231
+ }
216
232
 
217
- if (provider) {
218
- lines.push(ok(`Provider: ${provider.name} (${provider.dimensions}d)`));
219
- lines.push(ok('Status: ready'));
220
- lines.push(info('Search: hybrid (BM25 + vector)'));
221
- report.embedding = { mode, status: 'ready', provider: provider.name, dimensions: provider.dimensions };
233
+ if (controlPlaneEmbedding?.status === 'degraded') {
234
+ const kind = controlPlaneEmbedding.failure?.kind ?? 'upstream';
235
+ const retryAfterMs = controlPlaneEmbedding.failure?.retryAfterMs;
236
+ const retry = typeof retryAfterMs === 'number' && retryAfterMs > 0
237
+ ? `; retry in ${Math.ceil(retryAfterMs / 1000)}s`
238
+ : '';
239
+ lines.push(warn(`Status: degraded in the running control plane (${kind}${retry})`));
240
+ lines.push(info('Search: degraded to BM25 fulltext until the provider recovers'));
241
+ issues.push('The running control plane has an unavailable embedding provider. Check provider credentials, billing, or connectivity.');
242
+ report.embedding = { mode, status: 'degraded', failure: kind };
222
243
  } else {
223
- lines.push(warn('Status: temporarily unavailable'));
224
- lines.push(info('Search: degraded to BM25 fulltext (no vector similarity)'));
225
- issues.push(`Embedding mode is "${mode}" but provider failed to initialize. Check API keys/connectivity.`);
226
- report.embedding = { mode, status: 'temporarily_unavailable' };
244
+ const { getEmbeddingProvider } = await import('../../embedding/provider.js');
245
+ const provider = await getEmbeddingProvider();
246
+
247
+ if (provider) {
248
+ lines.push(ok(`Provider: ${provider.name} (${provider.dimensions}d)`));
249
+ lines.push(ok('Status: ready'));
250
+ lines.push(info('Search: hybrid (BM25 + vector)'));
251
+ report.embedding = { mode, status: 'ready', provider: provider.name, dimensions: provider.dimensions };
252
+ } else {
253
+ lines.push(warn('Status: temporarily unavailable'));
254
+ lines.push(info('Search: degraded to BM25 fulltext (no vector similarity)'));
255
+ issues.push(`Embedding mode is "${mode}" but provider failed to initialize. Check API keys/connectivity.`);
256
+ report.embedding = { mode, status: 'temporarily_unavailable' };
257
+ }
227
258
  }
228
259
 
229
260
  // Check cached embeddings count
@@ -4,8 +4,16 @@
4
4
  * Entry point called by agent hooks via stdin/stdout.
5
5
  * Reads agent's JSON from stdin, normalizes, auto-stores, outputs response.
6
6
  *
7
+ * This command is short-lived. A leftover `memorix hook` process is a bug:
8
+ * hosts invoke it on every event, and hung copies exhaust RAM.
9
+ *
7
10
  * Usage (called by agent hook configs, not by users directly):
8
11
  * memorix hook
12
+ *
13
+ * Env:
14
+ * MEMORIX_HOOK_TIMEOUT_MS — wall-clock budget (default 20000)
15
+ * MEMORIX_HOOK_STDIN_TIMEOUT_MS — stdin idle budget (default 3000)
16
+ * MEMORIX_HOOK_HEAP_MB — V8 heap for this command only (default 512)
9
17
  */
10
18
 
11
19
  import { defineCommand } from 'citty';
@@ -29,6 +37,10 @@ export default defineCommand({
29
37
  },
30
38
  run: async ({ args }) => {
31
39
  const { runHook } = await import('../../hooks/handler.js');
32
- await runHook(args.agent as string | undefined, args.event as string | undefined);
40
+ const { resolveHookTimeoutMs, runHookWithDeadline } = await import('../../hooks/lifecycle.js');
41
+ await runHookWithDeadline({
42
+ timeoutMs: resolveHookTimeoutMs(),
43
+ run: () => runHook(args.agent as string | undefined, args.event as string | undefined),
44
+ });
33
45
  },
34
46
  });
@@ -13,7 +13,9 @@ export default defineCommand({
13
13
  args: {
14
14
  task: { type: 'positional', description: 'Current continuation task', required: false },
15
15
  refresh: { type: 'string', description: 'Project scan policy: auto, always, or never' },
16
+ agent: { type: 'string', description: 'Optional target agent for compatible workflow selection' },
16
17
  json: { type: 'boolean', description: 'Emit machine-readable JSON output' },
18
+ briefJson: { type: 'boolean', description: 'Emit only the bounded agent brief and receipt JSON' },
17
19
  },
18
20
  async run({ args }) {
19
21
  await contextCommand.run?.({
@@ -21,7 +23,9 @@ export default defineCommand({
21
23
  _: [],
22
24
  input: args.task,
23
25
  refresh: args.refresh,
26
+ agent: args.agent,
24
27
  json: args.json,
28
+ briefJson: args.briefJson,
25
29
  resume: true,
26
30
  },
27
31
  rawArgs: [],
@@ -29,13 +29,19 @@ import { canManageObservation, filterReadableObservations } from '../../memory/v
29
29
  import { parseTcpPortOrReport } from '../port.js';
30
30
  import { mcpFileUriToPath } from '../mcp-root-path.js';
31
31
 
32
- export const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000;
32
+ /**
33
+ * HTTP MCP is a shared control plane. A normal thinking break should not
34
+ * silently invalidate a client session. Operators can choose a shorter GC
35
+ * window, or set 0 to disable idle GC entirely.
36
+ */
37
+ export const DEFAULT_SESSION_TIMEOUT_MS = 12 * 60 * 60 * 1000;
38
+ export const EXPIRED_SESSION_TTL_MS = 10 * 60 * 1000;
33
39
 
34
40
  export function parseSessionTimeoutMs(raw: string | undefined): number {
35
41
  const value = raw?.trim();
36
42
  if (!value) return DEFAULT_SESSION_TIMEOUT_MS;
37
43
  const parsed = Number(value);
38
- if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_SESSION_TIMEOUT_MS;
44
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_SESSION_TIMEOUT_MS;
39
45
  return Math.floor(parsed);
40
46
  }
41
47
 
@@ -132,7 +138,11 @@ export default defineCommand({
132
138
  console.error(`[memorix] HTTP transport starting on ${host}:${port}`);
133
139
  console.error(`[memorix] Project root: ${projectRoot}`);
134
140
  const sessionTimeoutMs = parseSessionTimeoutMs(process.env.MEMORIX_SESSION_TIMEOUT_MS);
135
- console.error(`[memorix] HTTP session idle timeout: ${Math.round(sessionTimeoutMs / 60000)}min (${sessionTimeoutMs}ms)`);
141
+ console.error(
142
+ sessionTimeoutMs === 0
143
+ ? '[memorix] HTTP session idle timeout: disabled'
144
+ : `[memorix] HTTP session idle timeout: ${Math.round(sessionTimeoutMs / 60000)}min (${sessionTimeoutMs}ms)`,
145
+ );
136
146
 
137
147
  // Per-project TeamStore cache (Option B) — each dataDir gets its own TeamStore instance.
138
148
  // This ensures true isolation between projects in HTTP control-plane mode.
@@ -166,6 +176,9 @@ export default defineCommand({
166
176
 
167
177
  // Session activity tracking (for GC timeout)
168
178
  const sessionLastActivity = new Map<string, number>();
179
+ // Keep a short-lived tombstone so an expired client gets an actionable
180
+ // reinitialize response instead of a generic bad request.
181
+ const expiredSessions = new Map<string, number>();
169
182
 
170
183
  // Probe session noise reduction: defer init logs, suppress short-lived sessions
171
184
  let suppressedProbeCount = 0;
@@ -182,6 +195,30 @@ export default defineCommand({
182
195
  }
183
196
  }
184
197
 
198
+ function forgetExpiredSessions(now = Date.now()): void {
199
+ for (const [sessionId, expiresAt] of expiredSessions) {
200
+ if (expiresAt <= now) expiredSessions.delete(sessionId);
201
+ }
202
+ }
203
+
204
+ function sendUnknownSession(res: ServerResponse, sessionId: string | undefined, jsonRpc = false): void {
205
+ const expired = Boolean(sessionId && expiredSessions.has(sessionId));
206
+ const message = expired
207
+ ? 'MCP session expired after inactivity. Initialize a new session and retry the request.'
208
+ : 'MCP session not found. Initialize a new session and retry the request.';
209
+ if (jsonRpc) {
210
+ res.writeHead(404, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify({
212
+ jsonrpc: '2.0',
213
+ error: { code: -32001, message },
214
+ id: null,
215
+ }));
216
+ return;
217
+ }
218
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
219
+ res.end(message);
220
+ }
221
+
185
222
  /**
186
223
  * Parse JSON body from IncomingMessage
187
224
  */
@@ -360,6 +397,11 @@ export default defineCommand({
360
397
  return;
361
398
  }
362
399
 
400
+ if (sessionId) {
401
+ sendUnknownSession(res, sessionId, true);
402
+ return;
403
+ }
404
+
363
405
  if (!sessionId && isInitializeRequest(body)) {
364
406
  // New session — create transport + server
365
407
  let createdState: SessionState | null = null;
@@ -488,8 +530,12 @@ export default defineCommand({
488
530
  async function handleGet(req: IncomingMessage, res: ServerResponse) {
489
531
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
490
532
  if (!sessionId || !sessions.has(sessionId)) {
491
- res.writeHead(400, { 'Content-Type': 'text/plain' });
492
- res.end('Invalid or missing session ID');
533
+ if (!sessionId) {
534
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
535
+ res.end('Missing MCP session ID');
536
+ return;
537
+ }
538
+ sendUnknownSession(res, sessionId);
493
539
  return;
494
540
  }
495
541
 
@@ -502,8 +548,12 @@ export default defineCommand({
502
548
  async function handleDelete(req: IncomingMessage, res: ServerResponse) {
503
549
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
504
550
  if (!sessionId || !sessions.has(sessionId)) {
505
- res.writeHead(400, { 'Content-Type': 'text/plain' });
506
- res.end('Invalid or missing session ID');
551
+ if (!sessionId) {
552
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
553
+ res.end('Missing MCP session ID');
554
+ return;
555
+ }
556
+ sendUnknownSession(res, sessionId);
507
557
  return;
508
558
  }
509
559
 
@@ -1414,6 +1464,7 @@ export default defineCommand({
1414
1464
  // Lightweight health check — responds immediately, no heavy init required.
1415
1465
  // This is the readiness signal for background start / status.
1416
1466
  if (url.pathname === '/health') {
1467
+ const { getEmbeddingRuntimeHealth } = await import('../../embedding/provider.js');
1417
1468
  res.writeHead(200, { 'Content-Type': 'application/json' });
1418
1469
  res.end(JSON.stringify({
1419
1470
  status: 'ok',
@@ -1421,6 +1472,7 @@ export default defineCommand({
1421
1472
  port,
1422
1473
  uptime: Math.round(process.uptime()),
1423
1474
  pid: process.pid,
1475
+ embedding: getEmbeddingRuntimeHealth(),
1424
1476
  }));
1425
1477
  return;
1426
1478
  }
@@ -1491,6 +1543,12 @@ export default defineCommand({
1491
1543
  await serveDashStatic(req, res);
1492
1544
  });
1493
1545
 
1546
+ // MCP session continuity is independent from one TCP socket. Keep Node's
1547
+ // socket timeout above its 5s default while the session map remains the
1548
+ // actual continuity boundary.
1549
+ httpServer.keepAliveTimeout = 60_000;
1550
+ httpServer.headersTimeout = 65_000;
1551
+
1494
1552
  httpServer.listen(port, host, () => {
1495
1553
  // Write readiness file — background start polls this for out-of-band readiness detection
1496
1554
  try {
@@ -1521,10 +1579,13 @@ export default defineCommand({
1521
1579
  const gcInterval = setInterval(() => {
1522
1580
  maybeProbeSummary(); // Flush suppressed probe count periodically
1523
1581
  const now = Date.now();
1582
+ forgetExpiredSessions(now);
1583
+ if (SESSION_TIMEOUT_MS === 0) return;
1524
1584
  for (const [sid, state] of sessions) {
1525
1585
  const lastActive = sessionLastActivity.get(sid) ?? 0;
1526
1586
  if (now - lastActive > SESSION_TIMEOUT_MS) {
1527
1587
  console.error(`[memorix] Session ${sid.slice(0, 8)}… timed out (idle ${Math.round((now - lastActive) / 60000)}min), closing`);
1588
+ expiredSessions.set(sid, now + EXPIRED_SESSION_TTL_MS);
1528
1589
  state.transport.close().catch(() => {});
1529
1590
  sessions.delete(sid);
1530
1591
  sessionLastActivity.delete(sid);
@@ -0,0 +1,76 @@
1
+ /**
2
+ * CLI heap selection for the tsup respawn banner.
3
+ *
4
+ * `memorix serve` and other long-lived commands need a large V8 heap for
5
+ * embeddings and the in-memory index. `memorix hook` is a per-event
6
+ * lifecycle process and must not reserve a 4 GB heap — copies accumulate.
7
+ */
8
+
9
+ export const DEFAULT_CLI_HEAP_MB = 4096;
10
+ export const DEFAULT_HOOK_HEAP_MB = 512;
11
+ export const MIN_HEAP_MB = 64;
12
+ export const MAX_HEAP_MB = 16_384;
13
+
14
+ function parseHeapMb(raw: string | undefined, fallback: number): number {
15
+ if (raw === undefined || raw.trim() === '') return fallback;
16
+ const parsed = Number(raw);
17
+ if (!Number.isFinite(parsed)) return fallback;
18
+ const mb = Math.floor(parsed);
19
+ if (mb < MIN_HEAP_MB || mb > MAX_HEAP_MB) return fallback;
20
+ return mb;
21
+ }
22
+
23
+ /**
24
+ * True when the CLI argv selects the short-lived `hook` command.
25
+ * `hooks` (install/status) is a different command and keeps the large heap.
26
+ */
27
+ export function isHookCliInvocation(argv: string[]): boolean {
28
+ for (let index = 0; index < argv.length; index += 1) {
29
+ const argument = argv[index];
30
+ if (argument === '--') break;
31
+ if (argument.startsWith('-')) {
32
+ if (!argument.includes('=') && argv[index + 1] && !argv[index + 1].startsWith('-')) {
33
+ index += 1;
34
+ }
35
+ continue;
36
+ }
37
+ return argument === 'hook';
38
+ }
39
+ return false;
40
+ }
41
+
42
+ export function resolveCliHeapMb(
43
+ argv: string[],
44
+ env: NodeJS.ProcessEnv = process.env,
45
+ ): number {
46
+ if (isHookCliInvocation(argv)) {
47
+ return parseHeapMb(env.MEMORIX_HOOK_HEAP_MB, DEFAULT_HOOK_HEAP_MB);
48
+ }
49
+ return parseHeapMb(env.MEMORIX_HEAP_MB, DEFAULT_CLI_HEAP_MB);
50
+ }
51
+
52
+ /**
53
+ * Self-contained JS prepended to the bundled CLI. It runs before the bundle
54
+ * loads, so the selection logic is inlined rather than imported.
55
+ */
56
+ export function buildCliHeapBannerPrelude(): string {
57
+ return [
58
+ '#!/usr/bin/env node',
59
+ 'import {spawnSync as __ms} from "node:child_process";',
60
+ 'import {fileURLToPath as __fu} from "node:url";',
61
+ 'if(!process.env.__MEMORIX_HEAP){process.env.__MEMORIX_HEAP="1";',
62
+ 'const __args=process.argv.slice(2);',
63
+ 'let __cmd;',
64
+ 'for(let __i=0;__i<__args.length;__i++){',
65
+ 'const __a=__args[__i];',
66
+ 'if(__a==="--")break;',
67
+ 'if(__a.startsWith("-")){if(!__a.includes("=")&&__args[__i+1]&&!__args[__i+1].startsWith("-"))__i++;continue;}',
68
+ '__cmd=__a;break;}',
69
+ `const __fallback=__cmd==="hook"?${DEFAULT_HOOK_HEAP_MB}:${DEFAULT_CLI_HEAP_MB};`,
70
+ 'const __raw=__cmd==="hook"?process.env.MEMORIX_HOOK_HEAP_MB:process.env.MEMORIX_HEAP_MB;',
71
+ 'const __n=Number(__raw);',
72
+ `const __heap=(Number.isFinite(__n)&&__n>=${MIN_HEAP_MB}&&__n<=${MAX_HEAP_MB})?String(Math.floor(__n)):String(__fallback);`,
73
+ 'let r=__ms(process.execPath,["--max-old-space-size="+__heap,__fu(import.meta.url),...process.argv.slice(2)],{stdio:"inherit",env:process.env,windowsHide:true});',
74
+ 'process.exit(r.status??1);}',
75
+ ].join('\n');
76
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
 
4
4
  export interface CliInvocation {
@@ -88,10 +88,13 @@ export function normalizeCliInvocation(argv: string[] = process.argv): CliInvoca
88
88
  }
89
89
 
90
90
  if (invocation.projectRoot) {
91
- const projectRoot = resolve(invocation.projectRoot);
92
- if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) {
93
- throw new Error(`CLI project directory does not exist: ${projectRoot}`);
91
+ const resolvedProjectRoot = resolve(invocation.projectRoot);
92
+ if (!existsSync(resolvedProjectRoot) || !statSync(resolvedProjectRoot).isDirectory()) {
93
+ throw new Error(`CLI project directory does not exist: ${resolvedProjectRoot}`);
94
94
  }
95
+ // Keep one project identity when an OS exposes the same directory through
96
+ // two paths (for example /var and /private/var on macOS).
97
+ const projectRoot = realpathSync(resolvedProjectRoot);
95
98
  process.chdir(projectRoot);
96
99
  process.env.MEMORIX_CLI_PROJECT_ROOT = projectRoot;
97
100
  invocation.projectRoot = projectRoot;
@@ -10,7 +10,7 @@ import {
10
10
  } from '../knowledge/workset.js';
11
11
  import type { ContextDeliveryTarget } from '../knowledge/context-assembly.js';
12
12
  import { sanitizeCredentials } from '../memory/secret-filter.js';
13
- import type { ObservationReader, ProjectInfo } from '../types.js';
13
+ import type { AgentTarget, ObservationReader, ProjectInfo } from '../types.js';
14
14
  import { backfillMissingObservationCodeRefs, type CodeRefBackfillResult } from './binder.js';
15
15
  import { collectCurrentProjectFacts, formatGitFact, type CurrentProjectFacts } from './current-facts.js';
16
16
  import { refreshProjectLite } from './lite-provider.js';
@@ -189,6 +189,8 @@ export async function buildAutoProjectContext(input: {
189
189
  dataDir: string;
190
190
  observations: ProjectContextObservation[];
191
191
  task?: string;
192
+ /** Explicit host target for task-compatible workflow selection. */
193
+ agent?: AgentTarget;
192
194
  refresh?: AutoContextRefreshMode;
193
195
  maxAgeMs?: number;
194
196
  now?: Date;
@@ -426,6 +428,7 @@ export async function buildAutoProjectContext(input: {
426
428
  projectId: input.project.id,
427
429
  dataDir: input.dataDir,
428
430
  ...(task ? { task } : {}),
431
+ ...(input.agent ? { agent: input.agent } : {}),
429
432
  lens: lens.id,
430
433
  startHere,
431
434
  ...(alwaysOn.profile.length > 0 || alwaysOn.state || alwaysOn.durable.length > 0
@@ -10,7 +10,7 @@ import type {
10
10
  } from './types.js';
11
11
  import type { CodeGraphStore } from './store.js';
12
12
  import { isCodeGraphExcludedPath } from './exclude.js';
13
- import type { ObservationReader } from '../types.js';
13
+ import type { AgentTarget, ObservationReader } from '../types.js';
14
14
 
15
15
  export interface ContextPackMemory {
16
16
  id: number;
@@ -278,6 +278,7 @@ export async function attachTaskWorkset(input: {
278
278
  providerQuality?: CodeGraphProviderQuality;
279
279
  runtimeCautions?: WorksetCaution[];
280
280
  reader?: ObservationReader;
281
+ agent?: AgentTarget;
281
282
  }): Promise<ContextPack> {
282
283
  const semanticStartHere = input.semanticCode
283
284
  ? [...input.semanticCode.relatedFiles, ...input.semanticCode.entryPoints.map(entry => entry.path)]
@@ -286,6 +287,7 @@ export async function attachTaskWorkset(input: {
286
287
  projectId: input.projectId,
287
288
  dataDir: input.dataDir,
288
289
  task: input.pack.task,
290
+ ...(input.agent ? { agent: input.agent } : {}),
289
291
  lens: input.lens,
290
292
  currentFacts: input.currentFacts ?? [],
291
293
  ...(input.codeState ? { codeState: input.codeState } : {}),
@@ -17,6 +17,8 @@ import type {
17
17
  // Keep the default useful in real sessions while still bounding a local call.
18
18
  export const DEFAULT_EXTERNAL_CODEGRAPH_TIMEOUT_MS = 1_200;
19
19
  export const MAX_EXTERNAL_CODEGRAPH_TIMEOUT_MS = 5_000;
20
+ /** Explicit index maintenance is opt-in and may legitimately take longer. */
21
+ export const EXTERNAL_CODEGRAPH_LIFECYCLE_TIMEOUT_MS = 30_000;
20
22
  export const MAX_EXTERNAL_CODEGRAPH_OUTPUT_BYTES = 256 * 1024;
21
23
  const MAX_EXTERNAL_NODE_COUNT = 16;
22
24
  const MAX_EXTERNAL_EDGE_COUNT = 24;
@@ -85,6 +87,19 @@ export interface ExternalCodeGraphContextResult extends InspectExternalCodeGraph
85
87
  caution?: string;
86
88
  }
87
89
 
90
+ /** Explicit lifecycle operations. Memorix never calls CodeGraph's `install`. */
91
+ export type ExternalCodeGraphLifecycleAction = 'init' | 'sync';
92
+
93
+ export interface ExternalCodeGraphLifecycleInput extends InspectExternalCodeGraphInput {
94
+ action: ExternalCodeGraphLifecycleAction;
95
+ }
96
+
97
+ export interface ExternalCodeGraphLifecycleResult extends InspectExternalCodeGraphResult {
98
+ action: ExternalCodeGraphLifecycleAction;
99
+ performed: boolean;
100
+ message: string;
101
+ }
102
+
88
103
  interface RawExternalStatus {
89
104
  initialized: boolean;
90
105
  projectPath: string;
@@ -437,6 +452,84 @@ export async function inspectExternalCodeGraph(input: InspectExternalCodeGraphIn
437
452
  return { health, quality: providerQuality({ mode, health }) };
438
453
  }
439
454
 
455
+ /**
456
+ * Run only a user-requested local lifecycle operation and re-inspect the
457
+ * result. This deliberately does not alter agent MCP files or invoke the
458
+ * upstream `codegraph install` command.
459
+ */
460
+ export async function runExternalCodeGraphLifecycle(
461
+ input: ExternalCodeGraphLifecycleInput,
462
+ ): Promise<ExternalCodeGraphLifecycleResult> {
463
+ const mode = input.mode ?? 'auto';
464
+ if (mode === 'off') {
465
+ const health: ExternalCodeGraphHealth = { state: 'disabled', reason: 'External CodeGraph is disabled by configuration.' };
466
+ return {
467
+ action: input.action,
468
+ performed: false,
469
+ message: 'External CodeGraph is disabled by configuration.',
470
+ health,
471
+ quality: providerQuality({ mode, health }),
472
+ };
473
+ }
474
+
475
+ const indexPath = join(input.projectRoot, '.codegraph');
476
+ if (input.action === 'sync' && !existsSync(indexPath)) {
477
+ const health: ExternalCodeGraphHealth = { state: 'not-detected', reason: 'No local .codegraph index is present for this project.' };
478
+ return {
479
+ action: input.action,
480
+ performed: false,
481
+ message: 'Initialize CodeGraph first with "memorix codegraph init".',
482
+ health,
483
+ quality: providerQuality({ mode, health }),
484
+ };
485
+ }
486
+
487
+ if (input.action === 'init' && existsSync(indexPath)) {
488
+ const inspected = await inspectExternalCodeGraph(input);
489
+ return {
490
+ action: input.action,
491
+ performed: false,
492
+ message: 'A local CodeGraph directory already exists; no initialization was performed.',
493
+ ...inspected,
494
+ };
495
+ }
496
+
497
+ const result = await runSafely(input.runner ?? defaultRunner, {
498
+ command: input.command,
499
+ args: input.action === 'init'
500
+ ? ['init', input.projectRoot]
501
+ : ['sync', input.projectRoot, '--quiet'],
502
+ cwd: input.projectRoot,
503
+ // A context read must stay fast; an explicit user-requested index sync is
504
+ // a different operation and gets its own bounded maintenance window.
505
+ timeoutMs: EXTERNAL_CODEGRAPH_LIFECYCLE_TIMEOUT_MS,
506
+ maxOutputBytes: MAX_EXTERNAL_CODEGRAPH_OUTPUT_BYTES,
507
+ });
508
+ if (!result.ok) {
509
+ const health = unavailableHealth(result);
510
+ return {
511
+ action: input.action,
512
+ performed: false,
513
+ message: 'CodeGraph ' + input.action + ' did not complete: ' + (health.reason ?? 'unknown local error'),
514
+ health,
515
+ quality: providerQuality({ mode, health }),
516
+ };
517
+ }
518
+
519
+ const inspected = await inspectExternalCodeGraph(input);
520
+ const remainsStale = inspected.health.state === 'stale';
521
+ return {
522
+ action: input.action,
523
+ performed: true,
524
+ message: input.action === 'init'
525
+ ? 'Local CodeGraph initialization completed. Run "memorix codegraph sync" when source changes.'
526
+ : remainsStale
527
+ ? 'Local CodeGraph synchronization completed, but the graph remains stale against the current worktree; this task will use Lite evidence.'
528
+ : 'Local CodeGraph synchronization completed and is ready for task-scoped semantic context.',
529
+ ...inspected,
530
+ };
531
+ }
532
+
440
533
  function safeRelativePath(projectRoot: string, value: unknown, exclude?: string[]): string | undefined {
441
534
  const path = safeText(value, 1_024);
442
535
  if (!path || isAbsolute(path)) return undefined;