memorix 1.9.4 → 1.9.6

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 (90) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +12 -6
  3. package/README.zh-CN.md +12 -6
  4. package/dist/cli/index.js +2533 -585
  5. package/dist/index.d.ts +15 -8
  6. package/dist/index.js +1201 -284
  7. package/dist/maintenance-runner.js +387 -216
  8. package/dist/media-audio-runner.js +300 -129
  9. package/dist/media-video-runner.js +300 -129
  10. package/dist/memcode-runtime/CHANGELOG.md +75 -1
  11. package/dist/sdk.d.ts +1 -0
  12. package/dist/sdk.js +1882 -998
  13. package/dist/types.d.ts +1 -1
  14. package/dist/vector-backfill-runner.js +300 -129
  15. package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
  16. package/docs/API_REFERENCE.md +39 -1
  17. package/docs/CONFIGURATION.md +58 -1
  18. package/docs/DEVELOPMENT.md +1 -1
  19. package/docs/DOCKER.md +23 -3
  20. package/docs/INTEGRATIONS.md +1 -1
  21. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +61 -1
  22. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
  23. package/docs/PERFORMANCE.md +46 -2
  24. package/docs/README.md +2 -1
  25. package/docs/SETUP.md +56 -1
  26. package/package.json +3 -2
  27. package/plugins/antigravity/memorix/plugin.json +1 -1
  28. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  29. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  30. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  31. package/plugins/copilot/memorix/plugin.json +1 -1
  32. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  33. package/plugins/hermes/memorix/plugin.yaml +1 -1
  34. package/plugins/omp/memorix/package.json +1 -1
  35. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  36. package/plugins/pi/memorix/package.json +1 -1
  37. package/server.json +2 -2
  38. package/src/cli/capability-map.ts +1 -0
  39. package/src/cli/command-guide.ts +15 -3
  40. package/src/cli/commands/agent-integrations.ts +57 -0
  41. package/src/cli/commands/background.ts +11 -2
  42. package/src/cli/commands/context.ts +2 -0
  43. package/src/cli/commands/continuity.ts +155 -0
  44. package/src/cli/commands/init.ts +5 -2
  45. package/src/cli/commands/serve-http.ts +435 -84
  46. package/src/cli/commands/setup.ts +77 -10
  47. package/src/cli/commands/sync-store.ts +47 -12
  48. package/src/cli/commands/sync.ts +26 -0
  49. package/src/cli/index.ts +3 -1
  50. package/src/cli/tui/ConfigureView.tsx +2 -0
  51. package/src/codegraph/auto-context.ts +17 -0
  52. package/src/config/legacy-loader.ts +2 -2
  53. package/src/config/resolved-config.ts +8 -5
  54. package/src/config/yaml-loader.ts +20 -10
  55. package/src/dashboard/server.ts +10 -12
  56. package/src/embedding/api-provider.ts +4 -2
  57. package/src/embedding/fastembed-provider.ts +47 -14
  58. package/src/embedding/transformers-provider.ts +29 -7
  59. package/src/evaluation/memory-quality-gate.ts +90 -0
  60. package/src/hooks/official-skills.ts +2 -0
  61. package/src/integrations/registry.ts +1 -1
  62. package/src/knowledge/context-assembly.ts +1 -0
  63. package/src/knowledge/context-receipt.ts +15 -0
  64. package/src/knowledge/task-continuity.ts +413 -0
  65. package/src/knowledge/workset.ts +77 -0
  66. package/src/llm/provider.ts +2 -1
  67. package/src/memory/graph.ts +10 -8
  68. package/src/memory/session.ts +1 -1
  69. package/src/orchestrate/coordinator.ts +60 -0
  70. package/src/orchestrate/effect-ledger.ts +160 -0
  71. package/src/orchestrate/pipeline-trace.ts +40 -6
  72. package/src/runtime/maintenance-jobs.ts +12 -6
  73. package/src/sdk.ts +24 -0
  74. package/src/server/mcp-binding-store.ts +14 -3
  75. package/src/server/modern-mcp-bridge.ts +153 -27
  76. package/src/server/tool-profile.ts +3 -2
  77. package/src/server.ts +131 -7
  78. package/src/store/file-lock.ts +28 -3
  79. package/src/store/graph-store.ts +178 -46
  80. package/src/store/persistence.ts +14 -9
  81. package/src/store/sqlite-db.ts +150 -7
  82. package/src/store/sqlite-store.ts +32 -27
  83. package/src/sync/engine.ts +9 -7
  84. package/src/sync/namespace.ts +23 -0
  85. package/src/sync/policy.ts +19 -4
  86. package/src/sync/store-port.ts +43 -13
  87. package/src/sync/types.ts +2 -0
  88. package/src/team/team-store.ts +62 -27
  89. package/src/types.ts +1 -1
  90. 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
- try {
305
- const raw = fs.readFileSync(jsonPath, 'utf-8');
306
- const observations: Observation[] = JSON.parse(raw);
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
- // Migrate counter
320
- const counterPath = path.join(this.dataDir, 'counter.json');
321
- if (fs.existsSync(counterPath)) {
322
- try {
323
- const counterData = JSON.parse(fs.readFileSync(counterPath, 'utf-8'));
324
- const nextId = counterData.nextId ?? counterData.next_id ?? (Math.max(...observations.map(o => o.id)) + 1);
325
- this.rawSaveIdCounter(nextId);
326
- } catch {
327
- // Fallback: derive from max observation ID
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
- } else {
331
- this.rawSaveIdCounter(Math.max(...observations.map(o => o.id)) + 1);
332
- }
333
-
334
- this.bumpGeneration();
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
- } catch (err) {
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 ──────────────────────────────────────────────────
@@ -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
  }
@@ -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
  }
@@ -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(observation: Observation, projectId: string): SyncEligibility {
14
- if (observation.projectId !== projectId) return { eligible: false, reason: 'other-project' };
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(observations: Observation[], projectId: string): {
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, projectId).eligible) eligible.push(observation);
48
+ if (syncEligibility(observation, scopeId, scope).eligible) eligible.push(observation);
34
49
  else excluded++;
35
50
  }
36
51
  return { eligible, excluded };
@@ -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(dataDir: string, store: ObservationStore, projectId: string): SqliteSyncStore {
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
- ensureSyncSchema(db, projectId);
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: () => 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(projectId, namespace, ensureDevice()) as Array<{ batchJson: string; statesJson: string }>).map((row) => ({
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(projectId, namespace, ensureDevice(), sequence);
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(projectId) as SyncRowState[];
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
- await tx.saveIdCounter(nextId + 1);
199
- return nextId;
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
- if (existing && existing.projectId !== state.projectId) {
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;
@@ -19,6 +19,14 @@ import path from 'node:path';
19
19
  import fs from 'node:fs';
20
20
  import type { TeamEventBus } from './event-bus.js';
21
21
 
22
+ export const DEFAULT_TEAM_QUERY_LIMIT = 100;
23
+ export const MAX_TEAM_QUERY_LIMIT = 500;
24
+
25
+ function clampTeamQueryLimit(limit: number | undefined): number {
26
+ if (!Number.isFinite(limit) || limit == null) return DEFAULT_TEAM_QUERY_LIMIT;
27
+ return Math.min(MAX_TEAM_QUERY_LIMIT, Math.max(1, Math.floor(limit)));
28
+ }
29
+
22
30
  // ── Types ───────────────────────────────────────────────────────────
23
31
 
24
32
  export interface TeamAgentRow {
@@ -467,37 +475,42 @@ export class TeamStore {
467
475
  return this.stmtAgentFindByInstance.get(projectId, agentType, instanceId) as TeamAgentRow | undefined;
468
476
  }
469
477
 
470
- listAgents(projectId: string, filter?: { status?: 'active' | 'inactive' }): TeamAgentRow[] {
471
- const all = this.stmtAgentListByProject.all(projectId) as TeamAgentRow[];
478
+ listAgents(projectId: string, filter?: { status?: 'active' | 'inactive'; limit?: number }): TeamAgentRow[] {
479
+ const limit = clampTeamQueryLimit(filter?.limit);
472
480
  if (filter?.status) {
473
- return all.filter(a => a.status === filter.status);
481
+ return this.db.prepare(
482
+ `SELECT * FROM team_agents WHERE project_id = ? AND status = ? ORDER BY joined_at DESC LIMIT ?`,
483
+ ).all(projectId, filter.status, limit) as TeamAgentRow[];
474
484
  }
475
- return all;
485
+ return this.db.prepare(
486
+ `SELECT * FROM team_agents WHERE project_id = ? ORDER BY joined_at DESC LIMIT ?`,
487
+ ).all(projectId, limit) as TeamAgentRow[];
476
488
  }
477
489
 
478
490
  /** List agents across all projects (for global scope) */
479
- listAllAgents(): TeamAgentRow[] {
491
+ listAllAgents(limit?: number): TeamAgentRow[] {
480
492
  if (!this.db) return [];
481
- return this.db.prepare('SELECT * FROM team_agents ORDER BY last_heartbeat DESC').all() as TeamAgentRow[];
493
+ return this.db.prepare('SELECT * FROM team_agents ORDER BY last_heartbeat DESC LIMIT ?').all(clampTeamQueryLimit(limit)) as TeamAgentRow[];
482
494
  }
483
495
 
484
496
  /** List locks across all projects (for global scope) */
485
- listAllLocks(): TeamLockRow[] {
497
+ listAllLocks(limit?: number): TeamLockRow[] {
486
498
  if (!this.db) return [];
487
499
  // Clean expired globally (not per-project) — bypass stmtLockDeleteExpired which scopes to project_id
488
500
  this.db.prepare('DELETE FROM team_locks WHERE expires_at <= ?').run(Date.now());
489
- return this.db.prepare('SELECT * FROM team_locks WHERE expires_at > ? ORDER BY locked_at DESC').all(Date.now()) as TeamLockRow[];
501
+ return this.db.prepare('SELECT * FROM team_locks WHERE expires_at > ? ORDER BY locked_at DESC LIMIT ?').all(Date.now(), clampTeamQueryLimit(limit)) as TeamLockRow[];
490
502
  }
491
503
 
492
504
  /** List tasks across all projects (for global scope) */
493
- listAllTasks(filter?: { available?: boolean }): TeamTaskRow[] {
505
+ listAllTasks(filter?: { available?: boolean; limit?: number }): TeamTaskRow[] {
494
506
  if (!this.db) return [];
507
+ const limit = clampTeamQueryLimit(filter?.limit);
495
508
  if (filter?.available) {
496
509
  return this.db.prepare(
497
- `SELECT t.* FROM team_tasks t WHERE t.status = 'pending' AND t.assignee_agent_id IS NULL ORDER BY t.created_at DESC`
498
- ).all() as TeamTaskRow[];
510
+ `SELECT t.* FROM team_tasks t WHERE t.status = 'pending' AND t.assignee_agent_id IS NULL ORDER BY t.created_at DESC LIMIT ?`
511
+ ).all(limit) as TeamTaskRow[];
499
512
  }
500
- return this.db.prepare('SELECT * FROM team_tasks ORDER BY created_at DESC').all() as TeamTaskRow[];
513
+ return this.db.prepare('SELECT * FROM team_tasks ORDER BY created_at DESC LIMIT ?').all(limit) as TeamTaskRow[];
501
514
  }
502
515
 
503
516
  heartbeat(agentId: string): boolean {
@@ -542,7 +555,10 @@ export class TeamStore {
542
555
  }
543
556
 
544
557
  getActiveCount(projectId: string): number {
545
- return this.listAgents(projectId, { status: 'active' }).length;
558
+ const row = this.db.prepare(
559
+ `SELECT COUNT(*) AS count FROM team_agents WHERE project_id = ? AND status = 'active'`,
560
+ ).get(projectId) as { count: number };
561
+ return Number(row?.count ?? 0);
546
562
  }
547
563
 
548
564
  // ═══════════════════════════════════════════════════════════════════
@@ -594,8 +610,14 @@ export class TeamStore {
594
610
  return row;
595
611
  }
596
612
 
597
- getInbox(projectId: string, agentId: string): TeamMessageRow[] {
598
- return this.stmtMsgInbox.all(projectId, agentId) as TeamMessageRow[];
613
+ getInbox(projectId: string, agentId: string, limit?: number): TeamMessageRow[] {
614
+ return this.db.prepare(`
615
+ SELECT * FROM team_messages
616
+ WHERE project_id = ?
617
+ AND (recipient_agent_id = ? OR recipient_agent_id IS NULL)
618
+ ORDER BY created_at ASC
619
+ LIMIT ?
620
+ `).all(projectId, agentId, clampTeamQueryLimit(limit)) as TeamMessageRow[];
599
621
  }
600
622
 
601
623
  getUnreadCount(projectId: string, agentId: string): number {
@@ -808,18 +830,27 @@ export class TeamStore {
808
830
  return info.changes;
809
831
  }
810
832
 
811
- listTasks(projectId: string, filter?: { status?: string; assignee?: string; available?: boolean }): TeamTaskRow[] {
812
- if (filter?.available) {
813
- return this.stmtTaskAvailable.all(projectId) as TeamTaskRow[];
814
- }
815
- const all = this.stmtTaskListByProject.all(projectId) as TeamTaskRow[];
833
+ listTasks(projectId: string, filter?: { status?: string; assignee?: string; available?: boolean; limit?: number }): TeamTaskRow[] {
834
+ const clauses = ['project_id = ?'];
835
+ const values: unknown[] = [projectId];
836
+ if (filter?.available) clauses.push("status = 'pending'", 'assignee_agent_id IS NULL');
816
837
  if (filter?.status) {
817
- return all.filter(t => t.status === filter.status);
838
+ clauses.push('status = ?');
839
+ values.push(filter.status);
818
840
  }
819
841
  if (filter?.assignee) {
820
- return all.filter(t => t.assignee_agent_id === filter.assignee);
842
+ clauses.push('assignee_agent_id = ?');
843
+ values.push(filter.assignee);
821
844
  }
822
- return all;
845
+ values.push(clampTeamQueryLimit(filter?.limit));
846
+ // Available work is a FIFO queue. Keep the general task board newest-first,
847
+ // but make dispatch order stable even when tasks share a millisecond timestamp.
848
+ const orderBy = filter?.available
849
+ ? 'created_at ASC, rowid ASC'
850
+ : 'created_at DESC, rowid DESC';
851
+ return this.db.prepare(
852
+ `SELECT * FROM team_tasks WHERE ${clauses.join(' AND ')} ORDER BY ${orderBy} LIMIT ?`,
853
+ ).all(...values) as TeamTaskRow[];
823
854
  }
824
855
 
825
856
  /**
@@ -833,7 +864,7 @@ export class TeamStore {
833
864
  if (!agent) return [];
834
865
  const agentRole = agent.role;
835
866
 
836
- const available = this.stmtTaskAvailable.all(projectId) as TeamTaskRow[];
867
+ const available = this.listTasks(projectId, { available: true, limit: MAX_TEAM_QUERY_LIMIT });
837
868
 
838
869
  // Filter out tasks whose required_role doesn't match
839
870
  const eligible = available.filter(t => !t.required_role || t.required_role === agentRole);
@@ -905,13 +936,17 @@ export class TeamStore {
905
936
  return row ?? null;
906
937
  }
907
938
 
908
- listLocks(projectId: string, agentId?: string): TeamLockRow[] {
939
+ listLocks(projectId: string, agentId?: string, limit?: number): TeamLockRow[] {
909
940
  // Clean expired first
910
941
  this.stmtLockDeleteExpired.run(projectId, Date.now());
911
942
  if (agentId) {
912
- return this.stmtLockListByAgent.all(projectId, agentId) as TeamLockRow[];
943
+ return this.db.prepare(
944
+ `SELECT * FROM team_locks WHERE project_id = ? AND locked_by = ? ORDER BY locked_at DESC LIMIT ?`,
945
+ ).all(projectId, agentId, clampTeamQueryLimit(limit)) as TeamLockRow[];
913
946
  }
914
- return this.stmtLockListByProject.all(projectId) as TeamLockRow[];
947
+ return this.db.prepare(
948
+ `SELECT * FROM team_locks WHERE project_id = ? ORDER BY locked_at DESC LIMIT ?`,
949
+ ).all(projectId, clampTeamQueryLimit(limit)) as TeamLockRow[];
915
950
  }
916
951
 
917
952
  releaseAllLocks(agentId: string): 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
+ }