memorix 1.9.3 → 1.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -1
- package/README.md +7 -6
- package/README.zh-CN.md +7 -6
- package/dist/cli/index.js +930 -330
- package/dist/index.d.ts +3 -1
- package/dist/index.js +289 -109
- package/dist/maintenance-runner.js +179 -117
- package/dist/media-audio-runner.js +92 -30
- package/dist/media-video-runner.js +92 -30
- package/dist/memcode-runtime/CHANGELOG.md +49 -1
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +1050 -847
- package/dist/types.d.ts +1 -1
- package/dist/vector-backfill-runner.js +92 -30
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
- package/docs/API_REFERENCE.md +3 -1
- package/docs/CONFIGURATION.md +15 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/DOCKER.md +23 -3
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/KNOWN_ISSUES_AND_ROADMAP.md +26 -1
- package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
- package/docs/README.md +1 -1
- package/docs/SETUP.md +55 -0
- package/package.json +2 -2
- package/plugins/antigravity/memorix/plugin.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/hermes/memorix/plugin.yaml +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/command-guide.ts +2 -2
- package/src/cli/commands/agent-integrations.ts +57 -0
- package/src/cli/commands/background.ts +11 -2
- package/src/cli/commands/media.ts +2 -0
- package/src/cli/commands/serve-http.ts +193 -42
- package/src/cli/commands/setup.ts +77 -10
- package/src/cli/commands/sync-store.ts +47 -12
- package/src/cli/commands/sync.ts +26 -0
- package/src/dashboard/server.ts +10 -12
- package/src/integrations/registry.ts +1 -1
- package/src/media/minimax.ts +26 -2
- package/src/media/video-jobs.ts +9 -1
- package/src/memory/graph.ts +10 -8
- package/src/memory/session.ts +1 -1
- package/src/sdk.ts +24 -0
- package/src/server/mcp-binding-store.ts +14 -3
- package/src/server.ts +5 -3
- package/src/store/file-lock.ts +28 -3
- package/src/store/graph-store.ts +178 -46
- package/src/store/persistence.ts +14 -9
- package/src/store/sqlite-db.ts +6 -3
- package/src/store/sqlite-store.ts +32 -27
- package/src/sync/engine.ts +9 -7
- package/src/sync/namespace.ts +23 -0
- package/src/sync/policy.ts +19 -4
- package/src/sync/store-port.ts +43 -13
- package/src/sync/types.ts +2 -0
- package/src/types.ts +1 -1
- package/src/workspace/mcp-adapters/grok.ts +32 -0
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import type { LexicalSearchHit } from './obs-store.js';
|
|
29
29
|
import path from 'node:path';
|
|
30
30
|
import fs from 'node:fs';
|
|
31
|
+
import { withFileLock } from './file-lock.js';
|
|
31
32
|
|
|
32
33
|
// ── Row ↔ Observation serialization ────────────────────────────────
|
|
33
34
|
|
|
@@ -294,49 +295,53 @@ export class SqliteBackend implements ObservationStore {
|
|
|
294
295
|
// ── Migration ────────────────────────────────────────────────────
|
|
295
296
|
|
|
296
297
|
private async migrateFromJsonIfNeeded(): Promise<void> {
|
|
297
|
-
// Only migrate if table is empty
|
|
298
|
-
const count = this.db.prepare(`SELECT COUNT(*) AS cnt FROM observations`).get();
|
|
299
|
-
if (count.cnt > 0) return;
|
|
300
|
-
|
|
301
298
|
const jsonPath = path.join(this.dataDir, 'observations.json');
|
|
302
299
|
if (!fs.existsSync(jsonPath)) return;
|
|
303
300
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
301
|
+
await withFileLock(this.dataDir, async () => {
|
|
302
|
+
// Re-check under the cross-process lock. Another initializer may have
|
|
303
|
+
// completed the migration while this process was waiting.
|
|
304
|
+
const count = this.db.prepare(`SELECT COUNT(*) AS cnt FROM observations`).get();
|
|
305
|
+
if (count.cnt > 0) return;
|
|
307
306
|
|
|
307
|
+
const raw = fs.readFileSync(jsonPath, 'utf-8');
|
|
308
|
+
const parsed: unknown = JSON.parse(raw);
|
|
309
|
+
if (!Array.isArray(parsed)) {
|
|
310
|
+
throw new Error('[memorix] observations.json migration refused: expected a JSON array');
|
|
311
|
+
}
|
|
312
|
+
const observations = parsed as Observation[];
|
|
308
313
|
if (observations.length === 0) return;
|
|
314
|
+
if (observations.some((obs) => !obs || !Number.isSafeInteger(obs.id) || obs.id < 1 || typeof obs.projectId !== 'string')) {
|
|
315
|
+
throw new Error('[memorix] observations.json migration refused: invalid observation record');
|
|
316
|
+
}
|
|
309
317
|
|
|
310
318
|
console.error(`[memorix] Migrating ${observations.length} observations from JSON to SQLite...`);
|
|
311
319
|
|
|
320
|
+
// The SQLite transaction makes the migration all-or-nothing. The JSON
|
|
321
|
+
// source remains intact until an operator explicitly removes it.
|
|
312
322
|
const insertMany = this.db.transaction((obsList: Observation[]) => {
|
|
313
323
|
for (const obs of obsList) {
|
|
314
324
|
this.stmtInsert.run(obsToRow(obs));
|
|
315
325
|
}
|
|
316
|
-
});
|
|
317
|
-
insertMany(observations);
|
|
318
326
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
this.rawSaveIdCounter(Math.max(...observations.map(o => o.id)) + 1);
|
|
327
|
+
const counterPath = path.join(this.dataDir, 'counter.json');
|
|
328
|
+
let counterNextId: number | undefined;
|
|
329
|
+
if (fs.existsSync(counterPath)) {
|
|
330
|
+
const counterData = JSON.parse(fs.readFileSync(counterPath, 'utf-8')) as { nextId?: unknown; next_id?: unknown };
|
|
331
|
+
const candidate = counterData.nextId ?? counterData.next_id;
|
|
332
|
+
if (candidate !== undefined && (!Number.isSafeInteger(candidate) || Number(candidate) < 1)) {
|
|
333
|
+
throw new Error('[memorix] counter.json migration refused: nextId must be a positive integer');
|
|
334
|
+
}
|
|
335
|
+
counterNextId = candidate === undefined ? undefined : Number(candidate);
|
|
329
336
|
}
|
|
330
|
-
|
|
331
|
-
this.rawSaveIdCounter(Math.max(
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
337
|
+
const maxId = Math.max(...obsList.map((obs) => obs.id));
|
|
338
|
+
this.rawSaveIdCounter(Math.max(counterNextId ?? 1, maxId + 1));
|
|
339
|
+
this.bumpGeneration();
|
|
340
|
+
});
|
|
341
|
+
insertMany(observations);
|
|
335
342
|
|
|
336
343
|
console.error(`[memorix] Migration complete. ${observations.length} observations now in SQLite.`);
|
|
337
|
-
}
|
|
338
|
-
console.error(`[memorix] JSON→SQLite migration failed (non-fatal, data preserved in JSON): ${err}`);
|
|
339
|
-
}
|
|
344
|
+
});
|
|
340
345
|
}
|
|
341
346
|
|
|
342
347
|
// ── Public read ──────────────────────────────────────────────────
|
package/src/sync/engine.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Observation } from '../types.js';
|
|
10
10
|
import { computeChanges, contentHash, nowIso } from './journal.js';
|
|
11
11
|
import { compareVersion, mergeOne } from './merge.js';
|
|
12
|
-
import { eligibleObservations } from './policy.js';
|
|
12
|
+
import { eligibleObservations, type SyncScope } from './policy.js';
|
|
13
13
|
import { isSafeSyncDeviceId } from './namespace.js';
|
|
14
14
|
import type {
|
|
15
15
|
ChangeBatch,
|
|
@@ -29,6 +29,7 @@ import { createHash } from 'node:crypto';
|
|
|
29
29
|
*/
|
|
30
30
|
export interface SyncStorePort {
|
|
31
31
|
projectId(): string;
|
|
32
|
+
scope(): SyncScope;
|
|
32
33
|
namespace(): string;
|
|
33
34
|
/** Stable device id for this replica. */
|
|
34
35
|
deviceId(): string;
|
|
@@ -83,6 +84,7 @@ export async function runSync(
|
|
|
83
84
|
remote: remote.kind,
|
|
84
85
|
namespace: remote.namespace,
|
|
85
86
|
projectId: store.projectId(),
|
|
87
|
+
scope: store.scope(),
|
|
86
88
|
deviceId: opts.deviceId,
|
|
87
89
|
pushed: 0,
|
|
88
90
|
pulledBatches: 0,
|
|
@@ -116,7 +118,7 @@ export async function runSync(
|
|
|
116
118
|
report.pending = pending.length;
|
|
117
119
|
if (!opts.dryRun) {
|
|
118
120
|
for (const pendingBatch of pending) {
|
|
119
|
-
validateBatches([pendingBatch.batch], store.projectId(), remote.namespace);
|
|
121
|
+
validateBatches([pendingBatch.batch], store.projectId(), remote.namespace, store.scope());
|
|
120
122
|
if (pendingBatch.batch.deviceId !== opts.deviceId) {
|
|
121
123
|
throw new Error('[memorix] sync outbox rejected: batch belongs to another device');
|
|
122
124
|
}
|
|
@@ -128,7 +130,7 @@ export async function runSync(
|
|
|
128
130
|
report.pending = 0;
|
|
129
131
|
}
|
|
130
132
|
const current = await store.loadAll();
|
|
131
|
-
const filtered = eligibleObservations(current, store.projectId());
|
|
133
|
+
const filtered = eligibleObservations(current, store.projectId(), store.scope());
|
|
132
134
|
report.eligible = filtered.eligible.length;
|
|
133
135
|
report.excluded = filtered.excluded;
|
|
134
136
|
const state = await store.loadState();
|
|
@@ -151,7 +153,7 @@ export async function runSync(
|
|
|
151
153
|
producedAt: nowIso(),
|
|
152
154
|
entries: chunk.map((c) => c.entry),
|
|
153
155
|
};
|
|
154
|
-
if (!opts.dryRun) validateBatches([batch], store.projectId(), remote.namespace);
|
|
156
|
+
if (!opts.dryRun) validateBatches([batch], store.projectId(), remote.namespace, store.scope());
|
|
155
157
|
report.pushed += chunk.length;
|
|
156
158
|
if (!opts.dryRun) {
|
|
157
159
|
const states = chunk.map((c) => ({ ...c.nextState, shippedSeq: sequence }));
|
|
@@ -177,7 +179,7 @@ export async function runSync(
|
|
|
177
179
|
// can skip later rows after page one.
|
|
178
180
|
const page = await remote.pull(pullSince, PULL_PAGE_SIZE, pageToken);
|
|
179
181
|
report.pulledBatches += page.batches.length;
|
|
180
|
-
validateBatches(page.batches, store.projectId(), remote.namespace);
|
|
182
|
+
validateBatches(page.batches, store.projectId(), remote.namespace, store.scope());
|
|
181
183
|
|
|
182
184
|
for (const batch of page.batches) {
|
|
183
185
|
if (batch.deviceId === opts.deviceId) {
|
|
@@ -254,7 +256,7 @@ export async function runSync(
|
|
|
254
256
|
}
|
|
255
257
|
}
|
|
256
258
|
|
|
257
|
-
function validateBatches(batches: ChangeBatch[], projectId: string, namespace: string): void {
|
|
259
|
+
function validateBatches(batches: ChangeBatch[], projectId: string, namespace: string, scope: SyncScope = 'project'): void {
|
|
258
260
|
for (const batch of batches) {
|
|
259
261
|
if (batch.formatVersion !== 3 || batch.projectId !== projectId || batch.namespace !== namespace) {
|
|
260
262
|
throw new Error('[memorix] sync batch rejected: wrong format, project, or remote namespace');
|
|
@@ -284,7 +286,7 @@ function validateBatches(batches: ChangeBatch[], projectId: string, namespace: s
|
|
|
284
286
|
}
|
|
285
287
|
if (
|
|
286
288
|
entry.kind === 'upsert'
|
|
287
|
-
&& (!Number.isSafeInteger(entry.row!.id) || entry.row!.id < 1 || !eligibleObservations([entry.row!], projectId).eligible.length)
|
|
289
|
+
&& (!Number.isSafeInteger(entry.row!.id) || entry.row!.id < 1 || !eligibleObservations([entry.row!], projectId, scope).eligible.length)
|
|
288
290
|
) {
|
|
289
291
|
throw new Error('[memorix] sync batch rejected: ineligible observation payload');
|
|
290
292
|
}
|
package/src/sync/namespace.ts
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
/** Sentinel project id for user-global store sync envelopes and local state. */
|
|
4
|
+
export const USER_SYNC_SCOPE_ID = '__user__';
|
|
5
|
+
|
|
6
|
+
/** Prefix used for explicit user-global remote namespaces. */
|
|
7
|
+
export const USER_SYNC_NAMESPACE = 'user-';
|
|
8
|
+
|
|
3
9
|
/** Stable, path-safe namespace for one canonical Git project. */
|
|
4
10
|
export function syncNamespace(projectId: string): string {
|
|
5
11
|
return `project-${createHash('sha256').update(projectId).digest('hex').slice(0, 32)}`;
|
|
6
12
|
}
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Remote namespace for `--scope user`. The operator must choose a stable
|
|
16
|
+
* namespace and reuse it on the same user's devices. Requiring the value is
|
|
17
|
+
* intentional: a fixed default would allow two operators sharing a relay to
|
|
18
|
+
* exchange otherwise project-visible memories.
|
|
19
|
+
*/
|
|
20
|
+
export function userSyncNamespace(env: NodeJS.ProcessEnv = process.env): string {
|
|
21
|
+
const override = env.MEMORIX_SYNC_USER_NAMESPACE?.trim();
|
|
22
|
+
if (!override) {
|
|
23
|
+
throw new Error('[memorix] MEMORIX_SYNC_USER_NAMESPACE is required for --scope user; use one stable per-user namespace across devices');
|
|
24
|
+
}
|
|
25
|
+
if (!isSafeSyncDeviceId(override)) {
|
|
26
|
+
throw new Error('[memorix] MEMORIX_SYNC_USER_NAMESPACE must match [A-Za-z0-9][A-Za-z0-9._-]{0,127}');
|
|
27
|
+
}
|
|
28
|
+
return `${USER_SYNC_NAMESPACE}${override}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
8
31
|
export function projectSyncPrefix(projectId: string): string {
|
|
9
32
|
return `p:${encodeURIComponent(projectId)}:`;
|
|
10
33
|
}
|
package/src/sync/policy.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Observation } from '../types.js';
|
|
2
2
|
|
|
3
|
+
export type SyncScope = 'project' | 'user';
|
|
4
|
+
|
|
3
5
|
export interface SyncEligibility {
|
|
4
6
|
eligible: boolean;
|
|
5
7
|
reason?: string;
|
|
@@ -9,9 +11,18 @@ export interface SyncEligibility {
|
|
|
9
11
|
* The first sync contract is deliberately conservative. A remote relay is not
|
|
10
12
|
* an agent-facing visibility boundary, so only durable project memory crosses
|
|
11
13
|
* it. Personal, team, candidate, and ephemeral records stay local.
|
|
14
|
+
*
|
|
15
|
+
* `--scope project` (default) also drops other-project rows. `--scope user`
|
|
16
|
+
* keeps those rows so one local SQLite can replicate every project.
|
|
12
17
|
*/
|
|
13
|
-
export function syncEligibility(
|
|
14
|
-
|
|
18
|
+
export function syncEligibility(
|
|
19
|
+
observation: Observation,
|
|
20
|
+
scopeId: string,
|
|
21
|
+
scope: SyncScope = 'project',
|
|
22
|
+
): SyncEligibility {
|
|
23
|
+
if (scope === 'project' && observation.projectId !== scopeId) {
|
|
24
|
+
return { eligible: false, reason: 'other-project' };
|
|
25
|
+
}
|
|
15
26
|
if ((observation.visibility ?? 'project') !== 'project') return { eligible: false, reason: 'non-project-visibility' };
|
|
16
27
|
if ((observation.sharedWithAgentIds?.length ?? 0) > 0) {
|
|
17
28
|
return { eligible: false, reason: 'agent-targeted' };
|
|
@@ -23,14 +34,18 @@ export function syncEligibility(observation: Observation, projectId: string): Sy
|
|
|
23
34
|
return { eligible: true };
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
export function eligibleObservations(
|
|
37
|
+
export function eligibleObservations(
|
|
38
|
+
observations: Observation[],
|
|
39
|
+
scopeId: string,
|
|
40
|
+
scope: SyncScope = 'project',
|
|
41
|
+
): {
|
|
27
42
|
eligible: Observation[];
|
|
28
43
|
excluded: number;
|
|
29
44
|
} {
|
|
30
45
|
const eligible: Observation[] = [];
|
|
31
46
|
let excluded = 0;
|
|
32
47
|
for (const observation of observations) {
|
|
33
|
-
if (syncEligibility(observation,
|
|
48
|
+
if (syncEligibility(observation, scopeId, scope).eligible) eligible.push(observation);
|
|
34
49
|
else excluded++;
|
|
35
50
|
}
|
|
36
51
|
return { eligible, excluded };
|
package/src/sync/store-port.ts
CHANGED
|
@@ -11,9 +11,10 @@ import type { Observation } from '../types.js';
|
|
|
11
11
|
import type { ObservationStore } from '../store/obs-store.js';
|
|
12
12
|
import { getDatabase } from '../store/sqlite-db.js';
|
|
13
13
|
import { CREATE_SYNC_TABLES } from './journal.js';
|
|
14
|
-
import { syncNamespace } from './namespace.js';
|
|
14
|
+
import { syncNamespace, userSyncNamespace, USER_SYNC_SCOPE_ID } from './namespace.js';
|
|
15
15
|
import type { SyncStorePort } from './engine.js';
|
|
16
16
|
import type { SyncConflict, SyncCursor, SyncPendingBatch, SyncRowState } from './types.js';
|
|
17
|
+
import type { SyncScope } from './policy.js';
|
|
17
18
|
|
|
18
19
|
const SEQ_KEY = 'local_sequence';
|
|
19
20
|
const DEVICE_KEY = 'device_id';
|
|
@@ -25,9 +26,16 @@ export interface SqliteSyncStore extends SyncStorePort {
|
|
|
25
26
|
deviceId(): string;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export function createSqliteSyncStore(
|
|
29
|
+
export function createSqliteSyncStore(
|
|
30
|
+
dataDir: string,
|
|
31
|
+
store: ObservationStore,
|
|
32
|
+
projectId: string,
|
|
33
|
+
options: { scope?: SyncScope } = {},
|
|
34
|
+
): SqliteSyncStore {
|
|
29
35
|
const db = getDatabase(dataDir);
|
|
30
|
-
|
|
36
|
+
const scope: SyncScope = options.scope ?? 'project';
|
|
37
|
+
const scopeId = scope === 'user' ? USER_SYNC_SCOPE_ID : projectId;
|
|
38
|
+
ensureSyncSchema(db, scope === 'user' ? LEGACY_PROJECT_ID : projectId);
|
|
31
39
|
const getMeta = db.prepare('SELECT value FROM sync_meta WHERE key = ?');
|
|
32
40
|
const setMeta = db.prepare('INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)');
|
|
33
41
|
const selState = db.prepare('SELECT projectId, syncKey, obsId, revision, writer, kind, contentHash, shippedSeq FROM sync_row_state WHERE projectId = ?');
|
|
@@ -68,7 +76,7 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
68
76
|
WHERE projectId = ? AND namespace = ? AND deviceId = ? AND sequence = ?`,
|
|
69
77
|
);
|
|
70
78
|
|
|
71
|
-
const namespace = syncNamespace(projectId);
|
|
79
|
+
const namespace = scope === 'user' ? userSyncNamespace() : syncNamespace(projectId);
|
|
72
80
|
const deviceFingerprint = process.env.MEMORIX_SYNC_DEVICE_FINGERPRINT?.trim()
|
|
73
81
|
|| `${process.platform}|${os.hostname()}|${os.homedir()}`;
|
|
74
82
|
|
|
@@ -101,7 +109,8 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
return {
|
|
104
|
-
projectId: () =>
|
|
112
|
+
projectId: () => scopeId,
|
|
113
|
+
scope: () => scope,
|
|
105
114
|
namespace: () => namespace,
|
|
106
115
|
deviceId: ensureDevice,
|
|
107
116
|
assertCanPublish,
|
|
@@ -152,7 +161,7 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
152
161
|
},
|
|
153
162
|
|
|
154
163
|
async loadPendingBatches(): Promise<SyncPendingBatch[]> {
|
|
155
|
-
return (selPending.all(
|
|
164
|
+
return (selPending.all(scopeId, namespace, ensureDevice()) as Array<{ batchJson: string; statesJson: string }>).map((row) => ({
|
|
156
165
|
batch: JSON.parse(row.batchJson),
|
|
157
166
|
states: JSON.parse(row.statesJson),
|
|
158
167
|
} as SyncPendingBatch));
|
|
@@ -160,7 +169,7 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
160
169
|
|
|
161
170
|
async enqueueBatch(pending: SyncPendingBatch): Promise<void> {
|
|
162
171
|
insertPending.run({
|
|
163
|
-
projectId,
|
|
172
|
+
projectId: scopeId,
|
|
164
173
|
namespace,
|
|
165
174
|
deviceId: pending.batch.deviceId,
|
|
166
175
|
sequence: pending.batch.sequence,
|
|
@@ -171,15 +180,15 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
171
180
|
},
|
|
172
181
|
|
|
173
182
|
async markBatchShipped(sequence: number): Promise<void> {
|
|
174
|
-
deletePending.run(
|
|
183
|
+
deletePending.run(scopeId, namespace, ensureDevice(), sequence);
|
|
175
184
|
},
|
|
176
185
|
|
|
177
186
|
async loadAll(): Promise<Observation[]> {
|
|
178
|
-
return store.loadByProject(projectId);
|
|
187
|
+
return scope === 'user' ? store.loadAll() : store.loadByProject(projectId);
|
|
179
188
|
},
|
|
180
189
|
|
|
181
190
|
async loadState(): Promise<Map<string, SyncRowState>> {
|
|
182
|
-
const rows = selState.all(
|
|
191
|
+
const rows = selState.all(scopeId) as SyncRowState[];
|
|
183
192
|
const map = new Map<string, SyncRowState>();
|
|
184
193
|
for (const r of rows) map.set(r.syncKey, r);
|
|
185
194
|
return map;
|
|
@@ -195,8 +204,13 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
195
204
|
async allocateId(): Promise<number> {
|
|
196
205
|
return store.atomic(async (tx) => {
|
|
197
206
|
const nextId = await tx.loadIdCounter();
|
|
198
|
-
|
|
199
|
-
|
|
207
|
+
// Direct imports and legacy migrations may not have advanced the
|
|
208
|
+
// counter. Reconcile it with the table while the same write lock is
|
|
209
|
+
// held so a sync pull cannot reuse an occupied local id.
|
|
210
|
+
const maxId = Number(db.prepare('SELECT COALESCE(MAX(id), 0) AS maxId FROM observations').get()?.maxId ?? 0);
|
|
211
|
+
const allocated = Math.max(nextId, maxId + 1);
|
|
212
|
+
await tx.saveIdCounter(allocated + 1);
|
|
213
|
+
return allocated;
|
|
200
214
|
});
|
|
201
215
|
},
|
|
202
216
|
|
|
@@ -229,7 +243,13 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
229
243
|
async applyRemove(id: number, state: SyncRowState): Promise<void> {
|
|
230
244
|
await store.atomic(async (tx) => {
|
|
231
245
|
const existing = await tx.getById(id);
|
|
232
|
-
|
|
246
|
+
const expectedProjectId = scope === 'user'
|
|
247
|
+
? sourceProjectIdFromSyncKey(state.syncKey)
|
|
248
|
+
: state.projectId;
|
|
249
|
+
if (!expectedProjectId) {
|
|
250
|
+
throw new Error('[memorix] sync refused to remove an observation without a project-scoped sync key');
|
|
251
|
+
}
|
|
252
|
+
if (existing && existing.projectId !== expectedProjectId) {
|
|
233
253
|
throw new Error('[memorix] sync refused to remove an observation from another project');
|
|
234
254
|
}
|
|
235
255
|
await tx.remove(id);
|
|
@@ -262,6 +282,16 @@ export function createSqliteSyncStore(dataDir: string, store: ObservationStore,
|
|
|
262
282
|
};
|
|
263
283
|
}
|
|
264
284
|
|
|
285
|
+
function sourceProjectIdFromSyncKey(syncKey: string): string | undefined {
|
|
286
|
+
const match = /^p:([^:]+):/.exec(syncKey);
|
|
287
|
+
if (!match) return undefined;
|
|
288
|
+
try {
|
|
289
|
+
return decodeURIComponent(match[1]);
|
|
290
|
+
} catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
265
295
|
function ensureSyncSchema(db: any, projectId: string): void {
|
|
266
296
|
db.exec(CREATE_SYNC_TABLES);
|
|
267
297
|
const columns = db.prepare('PRAGMA table_info(sync_row_state)').all() as Array<{ name: string }>;
|
package/src/sync/types.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* reaches the same final state (last-writer-wins, convergent).
|
|
18
18
|
*/
|
|
19
19
|
import type { Observation } from '../types.js';
|
|
20
|
+
import type { SyncScope } from './policy.js';
|
|
20
21
|
|
|
21
22
|
/** Kind of change carried in the journal. */
|
|
22
23
|
export type ChangeKind = 'upsert' | 'tombstone';
|
|
@@ -158,6 +159,7 @@ export interface SyncReport {
|
|
|
158
159
|
remote: string;
|
|
159
160
|
namespace: string;
|
|
160
161
|
projectId: string;
|
|
162
|
+
scope: SyncScope;
|
|
161
163
|
deviceId: string;
|
|
162
164
|
pushed: number;
|
|
163
165
|
pulledBatches: number;
|
package/src/types.ts
CHANGED
|
@@ -670,7 +670,7 @@ export interface MemoryRef {
|
|
|
670
670
|
/** MCP config format adapter interface */
|
|
671
671
|
export interface MCPConfigAdapter {
|
|
672
672
|
/** Setup-only adapters may support a host that does not participate in workspace sync. */
|
|
673
|
-
readonly source: AgentTarget | 'codebuddy' | 'dsh' | 'workbuddy';
|
|
673
|
+
readonly source: AgentTarget | 'codebuddy' | 'dsh' | 'workbuddy' | 'grok';
|
|
674
674
|
/** Parse MCP server entries from a config file */
|
|
675
675
|
parse(content: string): MCPServerEntry[];
|
|
676
676
|
/** Generate config file content from MCP server entries */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { MCPConfigAdapter, MCPServerEntry } from '../../types.js';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CodexMCPAdapter } from './codex.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Grok Build MCP config adapter.
|
|
8
|
+
* Format: TOML at $GROK_HOME/config.toml (default ~/.grok/config.toml) or
|
|
9
|
+
* .grok/config.toml (project-level). Same `[mcp_servers.<name>]` shape as Codex.
|
|
10
|
+
*
|
|
11
|
+
* Default setup leaves this file host-owned. Explicit `memorix setup --mcp http`
|
|
12
|
+
* writes a URL-only Memorix block.
|
|
13
|
+
*/
|
|
14
|
+
export class GrokMCPAdapter implements MCPConfigAdapter {
|
|
15
|
+
readonly source = 'grok' as const;
|
|
16
|
+
private readonly toml = new CodexMCPAdapter();
|
|
17
|
+
|
|
18
|
+
parse(content: string): MCPServerEntry[] {
|
|
19
|
+
return this.toml.parse(content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
generate(servers: MCPServerEntry[]): string {
|
|
23
|
+
return this.toml.generate(servers);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getConfigPath(_projectRoot?: string): string {
|
|
27
|
+
// Grok reads user-level ~/.grok/config.toml (or $GROK_HOME). Project
|
|
28
|
+
// `.grok/` is the hooks directory, not MCP config.
|
|
29
|
+
const grokHome = process.env.GROK_HOME?.trim() || join(homedir(), '.grok');
|
|
30
|
+
return join(grokHome, 'config.toml');
|
|
31
|
+
}
|
|
32
|
+
}
|