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.
- package/CHANGELOG.md +47 -0
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/cli/index.js +558 -102
- package/dist/cli/memcode.js +12 -1
- package/dist/index.js +221 -27
- package/dist/maintenance-runner.js +59 -11
- package/dist/media-audio-runner.js +59 -11
- package/dist/media-video-runner.js +59 -11
- package/dist/memcode-runtime/CHANGELOG.md +47 -0
- package/dist/sdk.js +220 -26
- package/dist/vector-backfill-runner.js +59 -11
- package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +1 -1
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
- package/docs/API_REFERENCE.md +2 -2
- package/docs/DEVELOPMENT.md +5 -3
- package/docs/DOCKER.md +5 -1
- package/docs/PERFORMANCE.md +2 -2
- package/docs/README.md +3 -1
- package/docs/SETUP.md +8 -2
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/agent-integrations.ts +9 -3
- package/src/cli/commands/codegraph.ts +48 -5
- package/src/cli/commands/context.ts +20 -4
- package/src/cli/commands/doctor.ts +46 -15
- package/src/cli/commands/hook.ts +13 -1
- package/src/cli/commands/resume.ts +4 -0
- package/src/cli/commands/serve-http.ts +68 -7
- package/src/cli/heap.ts +76 -0
- package/src/cli/invocation.ts +7 -4
- package/src/codegraph/auto-context.ts +4 -1
- package/src/codegraph/context-pack.ts +3 -1
- package/src/codegraph/external-provider.ts +93 -0
- package/src/embedding/provider.ts +76 -6
- package/src/hooks/handler.ts +9 -18
- package/src/hooks/installers/index.ts +1 -1
- package/src/hooks/lifecycle.ts +173 -0
- package/src/hooks/official-skills.ts +3 -3
- package/src/hooks/rules/memorix-agent-rules.md +1 -1
- package/src/knowledge/context-receipt.ts +41 -0
- package/src/knowledge/workflows.ts +6 -3
- package/src/knowledge/workset.ts +56 -2
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
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 (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
package/src/cli/commands/hook.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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);
|
package/src/cli/heap.ts
ADDED
|
@@ -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
|
+
}
|
package/src/cli/invocation.ts
CHANGED
|
@@ -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
|
|
92
|
-
if (!existsSync(
|
|
93
|
-
throw new Error(`CLI project directory does not exist: ${
|
|
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;
|