memorix 1.1.11 → 1.1.13
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 +25 -0
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dist/cli/index.js +679 -78
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +289 -44
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.js +252 -36
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +25 -0
- package/dist/memcode-runtime/package.json +5 -4
- package/dist/sdk.js +289 -44
- package/dist/sdk.js.map +1 -1
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +2 -2
- package/docs/DEVELOPMENT.md +3 -10
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/SETUP.md +3 -1
- package/docs/dev-log/progress.txt +34 -53
- package/docs/hooks-architecture.md +24 -12
- package/package.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +2 -1
- package/plugins/codex/memorix/hooks/hooks.json +30 -0
- package/src/cli/commands/agent-integrations.ts +384 -2
- package/src/cli/commands/hooks-install.ts +2 -1
- package/src/cli/commands/setup.ts +20 -2
- package/src/cli/tui/chat-service.ts +3 -9
- package/src/cli/tui/data.ts +9 -11
- package/src/embedding/api-provider.ts +105 -6
- package/src/embedding/provider.ts +31 -3
- package/src/hooks/handler.ts +51 -14
- package/src/hooks/installers/index.ts +48 -7
- package/src/hooks/normalizer.ts +15 -0
- package/src/memory/observations.ts +90 -15
- package/src/store/orama-store.ts +169 -17
|
@@ -14,6 +14,7 @@ import type { EmbeddingProvider } from './provider.js';
|
|
|
14
14
|
const CACHE_DIR = process.env.MEMORIX_DATA_DIR || join(homedir(), '.memorix', 'data');
|
|
15
15
|
const CACHE_FILE = join(CACHE_DIR, '.embedding-api-cache.json');
|
|
16
16
|
const DIMS_CACHE_FILE = join(CACHE_DIR, '.embedding-dims-cache.json');
|
|
17
|
+
const CACHE_META_FILE = join(CACHE_DIR, '.embedding-api-cache-meta.json');
|
|
17
18
|
|
|
18
19
|
const cache = new Map<string, number[]>();
|
|
19
20
|
const MAX_CACHE_SIZE = 10000;
|
|
@@ -82,6 +83,73 @@ function dimsCacheKey(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 're
|
|
|
82
83
|
].join('|');
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
interface CacheMetadataEntry {
|
|
87
|
+
namespace: string;
|
|
88
|
+
dimensions: number;
|
|
89
|
+
ts: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isValidDimension(value: unknown): value is number {
|
|
93
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The vector cache keeps a tiny, redundant namespace → dimensions map. It lets
|
|
98
|
+
* cache-first startup recover when the separate dimensions cache is missing.
|
|
99
|
+
*/
|
|
100
|
+
async function loadCachedVectorDimensions(
|
|
101
|
+
config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 'requestedDimensions'>,
|
|
102
|
+
): Promise<number | null> {
|
|
103
|
+
try {
|
|
104
|
+
const raw = await readFile(CACHE_META_FILE, 'utf-8');
|
|
105
|
+
const data = JSON.parse(raw);
|
|
106
|
+
const namespace = cacheNamespace(config);
|
|
107
|
+
if (!Array.isArray(data?.entries)) return null;
|
|
108
|
+
const entry = data.entries.find((candidate: unknown) =>
|
|
109
|
+
typeof candidate === 'object' &&
|
|
110
|
+
candidate !== null &&
|
|
111
|
+
(candidate as { namespace?: unknown }).namespace === namespace &&
|
|
112
|
+
isValidDimension((candidate as { dimensions?: unknown }).dimensions),
|
|
113
|
+
) as CacheMetadataEntry | undefined;
|
|
114
|
+
return entry?.dimensions ?? null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function saveCachedVectorDimensions(
|
|
121
|
+
config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 'requestedDimensions'>,
|
|
122
|
+
dimensions: number,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
if (!isValidDimension(dimensions)) return;
|
|
125
|
+
try {
|
|
126
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
127
|
+
let entries: CacheMetadataEntry[] = [];
|
|
128
|
+
try {
|
|
129
|
+
const raw = await readFile(CACHE_META_FILE, 'utf-8');
|
|
130
|
+
const data = JSON.parse(raw);
|
|
131
|
+
if (Array.isArray(data?.entries)) {
|
|
132
|
+
entries = data.entries.filter((candidate: unknown): candidate is CacheMetadataEntry =>
|
|
133
|
+
typeof candidate === 'object' &&
|
|
134
|
+
candidate !== null &&
|
|
135
|
+
typeof (candidate as { namespace?: unknown }).namespace === 'string' &&
|
|
136
|
+
isValidDimension((candidate as { dimensions?: unknown }).dimensions) &&
|
|
137
|
+
typeof (candidate as { ts?: unknown }).ts === 'number',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// The cache metadata is a best-effort acceleration layer.
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const namespace = cacheNamespace(config);
|
|
145
|
+
entries = entries.filter((entry) => entry.namespace !== namespace);
|
|
146
|
+
entries.push({ namespace, dimensions, ts: Date.now() });
|
|
147
|
+
await writeFile(CACHE_META_FILE, JSON.stringify({ version: 1, entries }));
|
|
148
|
+
} catch {
|
|
149
|
+
// A missing or read-only cache must never block embedding requests.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
85
153
|
/** Load cached probe dimensions from disk. Returns null if not cached. */
|
|
86
154
|
async function loadCachedDims(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'model' | 'requestedDimensions'>): Promise<number | null> {
|
|
87
155
|
try {
|
|
@@ -179,6 +247,7 @@ async function saveCachedDims(config: Pick<APIEmbeddingConfig, 'baseUrl' | 'mode
|
|
|
179
247
|
|
|
180
248
|
await writeFile(DIMS_CACHE_FILE, JSON.stringify({ entries }));
|
|
181
249
|
} catch { /* best-effort */ }
|
|
250
|
+
await saveCachedVectorDimensions(config, dimensions);
|
|
182
251
|
}
|
|
183
252
|
|
|
184
253
|
async function saveDiskCacheNow(): Promise<void> {
|
|
@@ -231,6 +300,14 @@ interface APIEmbeddingConfig {
|
|
|
231
300
|
requestedDimensions: number | null;
|
|
232
301
|
}
|
|
233
302
|
|
|
303
|
+
export interface APIEmbeddingProviderCreateOptions {
|
|
304
|
+
/**
|
|
305
|
+
* When false, only previously persisted dimension metadata may initialize
|
|
306
|
+
* the provider. This keeps startup/cache hydration off the remote API path.
|
|
307
|
+
*/
|
|
308
|
+
allowNetworkProbe?: boolean;
|
|
309
|
+
}
|
|
310
|
+
|
|
234
311
|
function resolveEnvEmbeddingApiKey(): string | undefined {
|
|
235
312
|
return process.env.MEMORIX_EMBEDDING_API_KEY;
|
|
236
313
|
}
|
|
@@ -272,18 +349,27 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
|
|
|
272
349
|
this.name = `api-${config.model.replace(/\//g, '-')}`;
|
|
273
350
|
}
|
|
274
351
|
|
|
275
|
-
static async create(): Promise<APIEmbeddingProvider
|
|
352
|
+
static async create(): Promise<APIEmbeddingProvider>;
|
|
353
|
+
static async create(options: APIEmbeddingProviderCreateOptions & { allowNetworkProbe: false }): Promise<APIEmbeddingProvider | null>;
|
|
354
|
+
static async create(options: APIEmbeddingProviderCreateOptions): Promise<APIEmbeddingProvider | null>;
|
|
355
|
+
static async create(options: APIEmbeddingProviderCreateOptions = {}): Promise<APIEmbeddingProvider | null> {
|
|
276
356
|
const config = APIEmbeddingProvider.resolveConfig();
|
|
277
|
-
|
|
278
|
-
// Start loading the 45MB+ embedding cache in the background (non-blocking).
|
|
279
|
-
// It will be awaited on first embed() call if not yet ready.
|
|
280
|
-
startDiskCacheLoad();
|
|
357
|
+
const allowNetworkProbe = options.allowNetworkProbe !== false;
|
|
281
358
|
|
|
282
359
|
// Try cached dimensions first to avoid a network probe on cold start
|
|
283
360
|
let probeDimensions = await loadCachedDims(config);
|
|
361
|
+
let dimensionSource: 'dims-cache' | 'vector-cache' | 'probe' = 'dims-cache';
|
|
362
|
+
if (probeDimensions === null) {
|
|
363
|
+
probeDimensions = await loadCachedVectorDimensions(config);
|
|
364
|
+
if (probeDimensions !== null) dimensionSource = 'vector-cache';
|
|
365
|
+
}
|
|
284
366
|
if (probeDimensions !== null) {
|
|
285
|
-
console.error(`[memorix] API embedding: ${config.model} @ ${config.baseUrl} (${probeDimensions}d) [
|
|
367
|
+
console.error(`[memorix] API embedding: ${config.model} @ ${config.baseUrl} (${probeDimensions}d) [${dimensionSource}]`);
|
|
368
|
+
if (dimensionSource === 'dims-cache') {
|
|
369
|
+
void saveCachedVectorDimensions(config, probeDimensions);
|
|
370
|
+
}
|
|
286
371
|
} else {
|
|
372
|
+
if (!allowNetworkProbe) return null;
|
|
287
373
|
probeDimensions = await APIEmbeddingProvider.probeAPI(config);
|
|
288
374
|
console.error(`[memorix] API embedding: ${config.model} @ ${config.baseUrl} (${probeDimensions}d)`);
|
|
289
375
|
// Persist for next cold start
|
|
@@ -293,6 +379,11 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
|
|
|
293
379
|
console.error(`[memorix] Dimension shortening: ${config.requestedDimensions}d requested`);
|
|
294
380
|
}
|
|
295
381
|
|
|
382
|
+
// The cache can only be used after dimensions are known. In cache-only
|
|
383
|
+
// startup with no metadata, skip parsing the potentially large cache file
|
|
384
|
+
// altogether and stay lexical until a normal embedding lane is needed.
|
|
385
|
+
startDiskCacheLoad();
|
|
386
|
+
|
|
296
387
|
return new APIEmbeddingProvider(config, probeDimensions);
|
|
297
388
|
}
|
|
298
389
|
|
|
@@ -519,6 +610,14 @@ export class APIEmbeddingProvider implements EmbeddingProvider {
|
|
|
519
610
|
return results;
|
|
520
611
|
}
|
|
521
612
|
|
|
613
|
+
async getCachedEmbeddings(texts: string[]): Promise<(number[] | null)[]> {
|
|
614
|
+
await ensureDiskCacheLoaded();
|
|
615
|
+
return texts.map((text) => {
|
|
616
|
+
const hash = textHash(normalizeText(text), this.cacheKeyNamespace);
|
|
617
|
+
return cache.get(hash) ?? null;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
522
621
|
getStats(): { totalTokens: number; totalApiCalls: number; cacheSize: number } {
|
|
523
622
|
return {
|
|
524
623
|
totalTokens: this.totalTokensUsed,
|
|
@@ -36,6 +36,13 @@ export interface EmbeddingProvider {
|
|
|
36
36
|
embed(text: string): Promise<number[]>;
|
|
37
37
|
/** Generate embeddings for multiple texts (batch) */
|
|
38
38
|
embedBatch(texts: string[]): Promise<number[][]>;
|
|
39
|
+
/** Return already-persisted embeddings without generating new vectors. */
|
|
40
|
+
getCachedEmbeddings?(texts: string[]): Promise<(number[] | null)[]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GetEmbeddingProviderOptions {
|
|
44
|
+
/** Avoid remote API dimension probes while preparing a startup index. */
|
|
45
|
+
allowNetworkProbe?: boolean;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/** Singleton provider instance (null = not available) */
|
|
@@ -172,10 +179,10 @@ async function createTransformersProvider(): Promise<EmbeddingProvider | null> {
|
|
|
172
179
|
}
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
async function createAPIProvider(): Promise<EmbeddingProvider | null> {
|
|
182
|
+
async function createAPIProvider(options?: GetEmbeddingProviderOptions): Promise<EmbeddingProvider | null> {
|
|
176
183
|
try {
|
|
177
184
|
const { APIEmbeddingProvider } = await import('./api-provider.js');
|
|
178
|
-
return await APIEmbeddingProvider.create();
|
|
185
|
+
return await APIEmbeddingProvider.create(options ?? {});
|
|
179
186
|
} catch (e) {
|
|
180
187
|
warnOnce(`[memorix] Failed to init API embedding: ${e instanceof Error ? e.message : e}`);
|
|
181
188
|
return null;
|
|
@@ -238,6 +245,13 @@ function wrapProvider(candidate: EmbeddingProvider): EmbeddingProvider {
|
|
|
238
245
|
throw error;
|
|
239
246
|
}
|
|
240
247
|
},
|
|
248
|
+
...(candidate.getCachedEmbeddings
|
|
249
|
+
? {
|
|
250
|
+
async getCachedEmbeddings(texts: string[]): Promise<(number[] | null)[]> {
|
|
251
|
+
return candidate.getCachedEmbeddings!(texts);
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
: {}),
|
|
241
255
|
};
|
|
242
256
|
}
|
|
243
257
|
|
|
@@ -256,10 +270,24 @@ let lastFailureTimestamp = 0;
|
|
|
256
270
|
*
|
|
257
271
|
* Controlled by MEMORIX_EMBEDDING environment variable (default: off).
|
|
258
272
|
*/
|
|
259
|
-
export async function getEmbeddingProvider(
|
|
273
|
+
export async function getEmbeddingProvider(
|
|
274
|
+
options: GetEmbeddingProviderOptions = {},
|
|
275
|
+
): Promise<EmbeddingProvider | null> {
|
|
260
276
|
// If we already have a successfully initialized provider, return it immediately
|
|
261
277
|
if (provider) return provider;
|
|
262
278
|
|
|
279
|
+
// Startup hydration is allowed to use API vectors already described by local
|
|
280
|
+
// dimension metadata, but it must never turn a missing cache into a network
|
|
281
|
+
// probe. A normal retrieval/write path will initialize the provider later.
|
|
282
|
+
if (options.allowNetworkProbe === false) {
|
|
283
|
+
const mode = getEmbeddingMode();
|
|
284
|
+
if (mode !== 'api' && !(mode === 'auto' && hasAPIEmbeddingConfig())) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const cached = await createAPIProvider({ allowNetworkProbe: false });
|
|
288
|
+
return cached ? wrapProvider(cached) : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
263
291
|
// If a previous attempt failed temporarily, allow retry after cooldown
|
|
264
292
|
if (lastInitWasTemporaryFailure) {
|
|
265
293
|
const elapsed = Date.now() - lastFailureTimestamp;
|
package/src/hooks/handler.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ObservationType } from '../types.js';
|
|
|
14
14
|
import { normalizeHookInput } from './normalizer.js';
|
|
15
15
|
import { detectBestPattern, patternToObservationType } from './pattern-detector.js';
|
|
16
16
|
import { isSignificantKnowledge, isRetrievedResult, isTrivialCommand } from './significance-filter.js';
|
|
17
|
-
import type { HookEvent, HookOutput, NormalizedHookInput } from './types.js';
|
|
17
|
+
import type { AgentName, HookEvent, HookOutput, NormalizedHookInput } from './types.js';
|
|
18
18
|
|
|
19
19
|
// ─── Constants ───
|
|
20
20
|
|
|
@@ -431,6 +431,55 @@ export async function handleHookEvent(input: NormalizedHookInput): Promise<{
|
|
|
431
431
|
};
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Convert Memorix's neutral hook response into the host-specific response
|
|
436
|
+
* schema expected by each supported agent.
|
|
437
|
+
*/
|
|
438
|
+
export function formatHookOutput(
|
|
439
|
+
agent: AgentName,
|
|
440
|
+
rawEventName: string,
|
|
441
|
+
output: HookOutput,
|
|
442
|
+
): Record<string, unknown> {
|
|
443
|
+
if (agent === 'codex') {
|
|
444
|
+
const codexOutput: Record<string, unknown> = { continue: output.continue };
|
|
445
|
+
if (output.stopReason) codexOutput.stopReason = output.stopReason;
|
|
446
|
+
|
|
447
|
+
// Codex adds SessionStart additionalContext directly to developer context.
|
|
448
|
+
// Capture-only events deliberately stay quiet so automatic memory never
|
|
449
|
+
// becomes a stream of status messages in the agent's working context.
|
|
450
|
+
if (rawEventName === 'SessionStart' && output.systemMessage) {
|
|
451
|
+
codexOutput.hookSpecificOutput = {
|
|
452
|
+
hookEventName: 'SessionStart',
|
|
453
|
+
additionalContext: output.systemMessage,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return codexOutput;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const finalOutput: Record<string, unknown> = { ...output };
|
|
461
|
+
const hookSpecificOutputEvents = new Set([
|
|
462
|
+
'PreToolUse',
|
|
463
|
+
'UserPromptSubmit',
|
|
464
|
+
'PostToolUse',
|
|
465
|
+
'postToolUse',
|
|
466
|
+
'preToolUse',
|
|
467
|
+
'userPromptSubmitted',
|
|
468
|
+
]);
|
|
469
|
+
|
|
470
|
+
if (rawEventName && hookSpecificOutputEvents.has(rawEventName)) {
|
|
471
|
+
const hookSpecificOutput: Record<string, unknown> = { hookEventName: rawEventName };
|
|
472
|
+
if (output.systemMessage) {
|
|
473
|
+
hookSpecificOutput.additionalContext = output.systemMessage;
|
|
474
|
+
} else if (rawEventName === 'UserPromptSubmit') {
|
|
475
|
+
hookSpecificOutput.additionalContext = '';
|
|
476
|
+
}
|
|
477
|
+
finalOutput.hookSpecificOutput = hookSpecificOutput;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return finalOutput;
|
|
481
|
+
}
|
|
482
|
+
|
|
434
483
|
// ─── Entry Point ───
|
|
435
484
|
|
|
436
485
|
/**
|
|
@@ -602,23 +651,11 @@ export async function runHook(agentOverride?: string, eventOverride?: string): P
|
|
|
602
651
|
?? (payload.hook_event_name as string)
|
|
603
652
|
?? (payload.hookEventName as string)
|
|
604
653
|
?? '';
|
|
605
|
-
const finalOutput: Record<string, unknown> = { ...output };
|
|
606
654
|
if (input.agent === 'antigravity') {
|
|
607
655
|
process.stdout.write(JSON.stringify(toAntigravityHookOutput(rawEventName, output)));
|
|
608
656
|
return;
|
|
609
657
|
}
|
|
610
|
-
|
|
611
|
-
const HSO_EVENTS = new Set(['PreToolUse', 'UserPromptSubmit', 'PostToolUse', 'postToolUse', 'preToolUse', 'userPromptSubmitted']);
|
|
612
|
-
if (rawEventName && HSO_EVENTS.has(rawEventName)) {
|
|
613
|
-
const hso: Record<string, unknown> = { hookEventName: rawEventName };
|
|
614
|
-
// additionalContext is REQUIRED for UserPromptSubmit, optional for others
|
|
615
|
-
if (output.systemMessage) {
|
|
616
|
-
hso.additionalContext = output.systemMessage;
|
|
617
|
-
} else if (rawEventName === 'UserPromptSubmit') {
|
|
618
|
-
hso.additionalContext = '';
|
|
619
|
-
}
|
|
620
|
-
finalOutput.hookSpecificOutput = hso;
|
|
621
|
-
}
|
|
658
|
+
const finalOutput = formatHookOutput(input.agent, rawEventName, output);
|
|
622
659
|
process.stdout.write(JSON.stringify(finalOutput));
|
|
623
660
|
}
|
|
624
661
|
|
|
@@ -30,6 +30,35 @@ function resolveHookCommand(): string {
|
|
|
30
30
|
return 'memorix';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function resolveWindowsMemorixShim(): string | null {
|
|
34
|
+
try {
|
|
35
|
+
const output = String(execSync('where.exe memorix.cmd', {
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
}));
|
|
40
|
+
return output
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map((candidate) => candidate.trim())
|
|
43
|
+
.find((candidate) => path.isAbsolute(candidate)) ?? null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* OpenCode starts plugins with its own environment on Windows. Resolve the npm
|
|
51
|
+
* shim while setup still has the user's normal PATH, then embed that stable
|
|
52
|
+
* path in the generated plugin instead of assuming OpenCode inherited it.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveOpenCodeHookCommand(
|
|
55
|
+
platform = process.platform,
|
|
56
|
+
resolveWindowsShim: () => string | null = resolveWindowsMemorixShim,
|
|
57
|
+
): string {
|
|
58
|
+
if (platform !== 'win32') return 'memorix';
|
|
59
|
+
return resolveWindowsShim() ?? 'memorix.cmd';
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
/**
|
|
34
63
|
* Generate Claude Code hook config.
|
|
35
64
|
* Format: .claude/settings.json
|
|
@@ -318,7 +347,7 @@ function generateKiroHookFiles(): Array<{ filename: string; content: string }> {
|
|
|
318
347
|
* protocol used by all agents. spawnSync works in both Node.js and Bun
|
|
319
348
|
* runtimes (OpenCode may fall back to Node.js on Windows).
|
|
320
349
|
*/
|
|
321
|
-
const OPENCODE_PLUGIN_VERSION =
|
|
350
|
+
const OPENCODE_PLUGIN_VERSION = 7;
|
|
322
351
|
|
|
323
352
|
const AGENT_SKILL_DIRS: Partial<Record<AgentName, { project: string; global?: string }>> = {
|
|
324
353
|
cursor: { project: path.join('.cursor', 'skills'), global: path.join('.cursor', 'skills') },
|
|
@@ -368,6 +397,7 @@ async function installOfficialSkillsForAgent(
|
|
|
368
397
|
}
|
|
369
398
|
|
|
370
399
|
function generateOpenCodePlugin(): string {
|
|
400
|
+
const hookCommand = JSON.stringify(resolveOpenCodeHookCommand());
|
|
371
401
|
return `/**
|
|
372
402
|
* Memorix - Cross-Agent Memory Bridge Plugin for OpenCode
|
|
373
403
|
* @generated-version ${OPENCODE_PLUGIN_VERSION}
|
|
@@ -386,6 +416,16 @@ export const MemorixPlugin = async ({ project, client, $, directory, worktree })
|
|
|
386
416
|
const sessionId = \`opencode-\${Date.now().toString(36)}-\${Math.random().toString(36).slice(2, 8)}\`;
|
|
387
417
|
let pendingAssistantResponse = null;
|
|
388
418
|
let lastDeliveredAssistantKey = '';
|
|
419
|
+
let hookFailureReported = false;
|
|
420
|
+
const hookCommand = ${hookCommand};
|
|
421
|
+
|
|
422
|
+
function reportHookFailure(eventName, detail) {
|
|
423
|
+
// OpenCode renders console.error output inside the conversation. Delivery is
|
|
424
|
+
// best-effort, so keep normal sessions quiet while retaining opt-in diagnostics.
|
|
425
|
+
if (process.env.MEMORIX_HOOK_DEBUG !== '1' || hookFailureReported) return;
|
|
426
|
+
hookFailureReported = true;
|
|
427
|
+
console.error('[memorix-plugin] hook delivery failed:', eventName, detail);
|
|
428
|
+
}
|
|
389
429
|
|
|
390
430
|
/**
|
|
391
431
|
* Send event JSON to \`memorix hook\` via child_process.spawnSync.
|
|
@@ -401,8 +441,7 @@ export const MemorixPlugin = async ({ project, client, $, directory, worktree })
|
|
|
401
441
|
const data = JSON.stringify(payload);
|
|
402
442
|
const eventName = payload.hook_event_name || 'unknown';
|
|
403
443
|
try {
|
|
404
|
-
const
|
|
405
|
-
const result = spawnSync(cmd, ['hook'], {
|
|
444
|
+
const result = spawnSync(hookCommand, ['hook'], {
|
|
406
445
|
input: data,
|
|
407
446
|
timeout: 10_000,
|
|
408
447
|
encoding: 'utf-8',
|
|
@@ -410,12 +449,14 @@ export const MemorixPlugin = async ({ project, client, $, directory, worktree })
|
|
|
410
449
|
shell: process.platform === 'win32',
|
|
411
450
|
});
|
|
412
451
|
if (result.status !== 0) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
452
|
+
reportHookFailure(eventName, {
|
|
453
|
+
exit: result.status,
|
|
454
|
+
stderr: (result.stderr || '').slice(0, 200),
|
|
455
|
+
error: result.error?.message,
|
|
456
|
+
});
|
|
416
457
|
}
|
|
417
458
|
} catch (e) {
|
|
418
|
-
|
|
459
|
+
reportHookFailure(eventName, e?.message ?? e);
|
|
419
460
|
}
|
|
420
461
|
}
|
|
421
462
|
|
package/src/hooks/normalizer.ts
CHANGED
|
@@ -201,6 +201,10 @@ function extractEventName(payload: Record<string, unknown>, agent: AgentName): s
|
|
|
201
201
|
return (payload.hook_event_name as string) ?? inferCursorEvent(payload);
|
|
202
202
|
case 'claude':
|
|
203
203
|
return (payload.hook_event_name as string) ?? '';
|
|
204
|
+
case 'codex':
|
|
205
|
+
// Codex uses the same lifecycle event field names as Claude Code, but
|
|
206
|
+
// the bundled plugin supplies an explicit agent identity.
|
|
207
|
+
return (payload.hook_event_name as string) ?? '';
|
|
204
208
|
case 'antigravity':
|
|
205
209
|
case 'gemini-cli':
|
|
206
210
|
// Gemini CLI uses hook_event_name; Antigravity hooks.json commands pass
|
|
@@ -272,6 +276,17 @@ function normalizeClaude(payload: Record<string, unknown>, event: HookEvent): Pa
|
|
|
272
276
|
result.userPrompt = payload.prompt as string;
|
|
273
277
|
}
|
|
274
278
|
|
|
279
|
+
// Codex Stop provides the final response under last_assistant_message.
|
|
280
|
+
// Accept the adjacent field name too so hosts with the same payload shape
|
|
281
|
+
// can preserve the useful end-of-turn summary.
|
|
282
|
+
const assistantMessage = firstString(
|
|
283
|
+
payload.last_assistant_message,
|
|
284
|
+
payload.assistant_message,
|
|
285
|
+
);
|
|
286
|
+
if (assistantMessage) {
|
|
287
|
+
result.aiResponse = assistantMessage;
|
|
288
|
+
}
|
|
289
|
+
|
|
275
290
|
return result;
|
|
276
291
|
}
|
|
277
292
|
|
|
@@ -16,8 +16,11 @@ import {
|
|
|
16
16
|
resetDb,
|
|
17
17
|
generateEmbedding,
|
|
18
18
|
batchGenerateEmbeddings,
|
|
19
|
+
getDb,
|
|
20
|
+
getDeferredCachedVectorHydration,
|
|
19
21
|
getVectorDimensions,
|
|
20
|
-
|
|
22
|
+
hydrateIndexForStartup,
|
|
23
|
+
hasObservationVector,
|
|
21
24
|
isEmbeddingEnabled,
|
|
22
25
|
makeOramaObservationId,
|
|
23
26
|
getLastSearchMode,
|
|
@@ -40,6 +43,7 @@ let searchIndexPrepared = false;
|
|
|
40
43
|
// Enables observability ("how many memories lack vectors?") and backfill.
|
|
41
44
|
const vectorMissingIds = new Set<number>();
|
|
42
45
|
let vectorBackfillRunning = false;
|
|
46
|
+
let vectorSchemaUpgradePromise: Promise<boolean> | null = null;
|
|
43
47
|
let lastVectorBackfill: {
|
|
44
48
|
attempted: number;
|
|
45
49
|
succeeded: number;
|
|
@@ -119,7 +123,30 @@ async function bindObservationCodeRefsBestEffort(observation: Observation): Prom
|
|
|
119
123
|
function isVectorCompatibleWithCurrentIndex(embedding: number[] | null): boolean {
|
|
120
124
|
if (!embedding) return false;
|
|
121
125
|
const vectorDimensions = getVectorDimensions();
|
|
122
|
-
return vectorDimensions
|
|
126
|
+
return vectorDimensions !== null &&
|
|
127
|
+
embedding.length === vectorDimensions &&
|
|
128
|
+
embedding.every(Number.isFinite);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A cache-less API startup deliberately creates a lexical index so MCP can
|
|
133
|
+
* answer immediately. Once the background lane obtains its first vector, the
|
|
134
|
+
* in-memory index must be rebuilt with a vector field in this same process.
|
|
135
|
+
*/
|
|
136
|
+
async function upgradeVectorSchemaAfterFirstEmbedding(embedding: number[]): Promise<boolean> {
|
|
137
|
+
if (isVectorCompatibleWithCurrentIndex(embedding)) return true;
|
|
138
|
+
if (getVectorDimensions() !== null || !embedding.every(Number.isFinite)) return false;
|
|
139
|
+
|
|
140
|
+
const targetProjectDir = projectDir;
|
|
141
|
+
if (!vectorSchemaUpgradePromise) {
|
|
142
|
+
vectorSchemaUpgradePromise = reindexObservations()
|
|
143
|
+
.then(() => projectDir === targetProjectDir && isVectorCompatibleWithCurrentIndex(embedding))
|
|
144
|
+
.catch(() => false)
|
|
145
|
+
.finally(() => {
|
|
146
|
+
vectorSchemaUpgradePromise = null;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return vectorSchemaUpgradePromise;
|
|
123
150
|
}
|
|
124
151
|
|
|
125
152
|
/**
|
|
@@ -134,6 +161,7 @@ export async function initObservations(dir: string): Promise<void> {
|
|
|
134
161
|
nextId = await store.loadIdCounter();
|
|
135
162
|
projectDir = dir;
|
|
136
163
|
searchIndexPrepared = false;
|
|
164
|
+
vectorSchemaUpgradePromise = null;
|
|
137
165
|
}
|
|
138
166
|
|
|
139
167
|
/**
|
|
@@ -411,6 +439,9 @@ export async function storeObservation(input: {
|
|
|
411
439
|
const searchableText = [input.title, input.narrative, ...(input.facts ?? [])].join(' ');
|
|
412
440
|
generateEmbedding(searchableText).then(async (embedding) => {
|
|
413
441
|
if (embedding) {
|
|
442
|
+
if (!isVectorCompatibleWithCurrentIndex(embedding)) {
|
|
443
|
+
await upgradeVectorSchemaAfterFirstEmbedding(embedding);
|
|
444
|
+
}
|
|
414
445
|
if (!isVectorCompatibleWithCurrentIndex(embedding)) {
|
|
415
446
|
const vectorDimensions = getVectorDimensions();
|
|
416
447
|
console.error(
|
|
@@ -561,6 +592,13 @@ async function upsertObservation(
|
|
|
561
592
|
vectorMissingIds.add(obsId);
|
|
562
593
|
generateEmbedding(searchableText).then(async (embedding) => {
|
|
563
594
|
if (embedding) {
|
|
595
|
+
if (!isVectorCompatibleWithCurrentIndex(embedding)) {
|
|
596
|
+
await upgradeVectorSchemaAfterFirstEmbedding(embedding);
|
|
597
|
+
}
|
|
598
|
+
if (!isVectorCompatibleWithCurrentIndex(embedding)) {
|
|
599
|
+
queueVectorBackfill(existing.projectId);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
564
602
|
try {
|
|
565
603
|
const { removeObservation: removeObs } = await import('../store/orama-store.js');
|
|
566
604
|
await removeObs(makeOramaObservationId(existing.projectId, obsId));
|
|
@@ -778,20 +816,36 @@ export async function reindexObservations(): Promise<number> {
|
|
|
778
816
|
searchIndexPrepared = false;
|
|
779
817
|
vectorMissingIds.clear();
|
|
780
818
|
|
|
781
|
-
//
|
|
819
|
+
// Prefer already-persisted vectors for remote providers. This restores semantic
|
|
820
|
+
// recall after a restart without waiting on the network; cache misses remain
|
|
821
|
+
// in the existing bounded background recovery lane.
|
|
782
822
|
let embeddings: (number[] | null)[] = observations.map(() => null);
|
|
783
823
|
const provider = await getEmbeddingProvider();
|
|
784
824
|
const canBatchEmbedAtStartup = provider !== null && !provider.name.startsWith('api-');
|
|
785
825
|
|
|
826
|
+
// This is an explicit rebuild path. Establish the current Orama schema before
|
|
827
|
+
// accepting cached vectors so a stale cache entry cannot make an observation
|
|
828
|
+
// disappear from the lexical index on insert.
|
|
829
|
+
await getDb();
|
|
830
|
+
|
|
831
|
+
const texts = observations.map(obs =>
|
|
832
|
+
[obs.title, obs.narrative, ...obs.facts].join(' '),
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
if (provider?.getCachedEmbeddings) {
|
|
836
|
+
try {
|
|
837
|
+
embeddings = await provider.getCachedEmbeddings(texts);
|
|
838
|
+
} catch {
|
|
839
|
+
// Cache lookup is optional; remote cache misses continue to background recovery.
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
786
843
|
if (provider && !canBatchEmbedAtStartup) {
|
|
787
|
-
console.error('[memorix] Startup reindex:
|
|
844
|
+
console.error('[memorix] Startup reindex: restored cached API embeddings; uncached vectors stay in background recovery');
|
|
788
845
|
}
|
|
789
846
|
|
|
790
847
|
if (canBatchEmbedAtStartup) {
|
|
791
848
|
try {
|
|
792
|
-
const texts = observations.map(obs =>
|
|
793
|
-
[obs.title, obs.narrative, ...obs.facts].join(' '),
|
|
794
|
-
);
|
|
795
849
|
embeddings = await batchGenerateEmbeddings(texts);
|
|
796
850
|
// Batch embedding failed — fall back to no embeddings
|
|
797
851
|
} catch {
|
|
@@ -857,20 +911,38 @@ export async function reindexObservations(): Promise<number> {
|
|
|
857
911
|
export async function prepareSearchIndex(): Promise<number> {
|
|
858
912
|
if (searchIndexPrepared) return 0;
|
|
859
913
|
|
|
860
|
-
const count = await
|
|
914
|
+
const count = await hydrateIndexForStartup(observations as unknown as any[]);
|
|
861
915
|
if (count === 0) {
|
|
862
916
|
searchIndexPrepared = true;
|
|
863
917
|
return 0;
|
|
864
918
|
}
|
|
865
919
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
920
|
+
const hydratedProjectDir = projectDir;
|
|
921
|
+
const queueMissingVectors = () => {
|
|
922
|
+
if (projectDir !== hydratedProjectDir) return;
|
|
923
|
+
vectorMissingIds.clear();
|
|
924
|
+
if (!isEmbeddingExplicitlyDisabled()) {
|
|
925
|
+
const projectsWithMissingVectors = new Set<string>();
|
|
926
|
+
for (const obs of observations) {
|
|
927
|
+
// Queue ALL statuses for vector backfill — status filtering happens at query time,
|
|
928
|
+
// not at index time. Omitting non-active observations here would permanently
|
|
929
|
+
// exclude resolved/archived memories from hybrid search after restart.
|
|
930
|
+
if (!hasObservationVector(obs.projectId, obs.id)) {
|
|
931
|
+
vectorMissingIds.add(obs.id);
|
|
932
|
+
projectsWithMissingVectors.add(obs.projectId);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
for (const projectId of projectsWithMissingVectors) queueVectorBackfill(projectId);
|
|
873
936
|
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const cacheHydration = getDeferredCachedVectorHydration();
|
|
940
|
+
if (cacheHydration) {
|
|
941
|
+
// Let the disk cache finish outside the interactive startup path. Misses are
|
|
942
|
+
// queued only after it has had a chance to restore compatible vectors.
|
|
943
|
+
void cacheHydration.finally(queueMissingVectors);
|
|
944
|
+
} else {
|
|
945
|
+
queueMissingVectors();
|
|
874
946
|
}
|
|
875
947
|
|
|
876
948
|
searchIndexPrepared = true;
|
|
@@ -987,6 +1059,9 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
987
1059
|
try {
|
|
988
1060
|
const embedding = await generateEmbedding(text);
|
|
989
1061
|
if (embedding) {
|
|
1062
|
+
if (!isVectorCompatibleWithCurrentIndex(embedding)) {
|
|
1063
|
+
await upgradeVectorSchemaAfterFirstEmbedding(embedding);
|
|
1064
|
+
}
|
|
990
1065
|
if (!isVectorCompatibleWithCurrentIndex(embedding)) {
|
|
991
1066
|
const vectorDimensions = getVectorDimensions();
|
|
992
1067
|
console.error(
|