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.
- package/CHANGELOG.md +75 -1
- package/README.md +12 -6
- package/README.zh-CN.md +12 -6
- package/dist/cli/index.js +2533 -585
- package/dist/index.d.ts +15 -8
- package/dist/index.js +1201 -284
- package/dist/maintenance-runner.js +387 -216
- package/dist/media-audio-runner.js +300 -129
- package/dist/media-video-runner.js +300 -129
- package/dist/memcode-runtime/CHANGELOG.md +75 -1
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +1882 -998
- package/dist/types.d.ts +1 -1
- package/dist/vector-backfill-runner.js +300 -129
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
- package/docs/API_REFERENCE.md +39 -1
- package/docs/CONFIGURATION.md +58 -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 +61 -1
- package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
- package/docs/PERFORMANCE.md +46 -2
- package/docs/README.md +2 -1
- package/docs/SETUP.md +56 -1
- package/package.json +3 -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/capability-map.ts +1 -0
- package/src/cli/command-guide.ts +15 -3
- package/src/cli/commands/agent-integrations.ts +57 -0
- package/src/cli/commands/background.ts +11 -2
- package/src/cli/commands/context.ts +2 -0
- package/src/cli/commands/continuity.ts +155 -0
- package/src/cli/commands/init.ts +5 -2
- package/src/cli/commands/serve-http.ts +435 -84
- 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/cli/index.ts +3 -1
- package/src/cli/tui/ConfigureView.tsx +2 -0
- package/src/codegraph/auto-context.ts +17 -0
- package/src/config/legacy-loader.ts +2 -2
- package/src/config/resolved-config.ts +8 -5
- package/src/config/yaml-loader.ts +20 -10
- package/src/dashboard/server.ts +10 -12
- package/src/embedding/api-provider.ts +4 -2
- package/src/embedding/fastembed-provider.ts +47 -14
- package/src/embedding/transformers-provider.ts +29 -7
- package/src/evaluation/memory-quality-gate.ts +90 -0
- package/src/hooks/official-skills.ts +2 -0
- package/src/integrations/registry.ts +1 -1
- package/src/knowledge/context-assembly.ts +1 -0
- package/src/knowledge/context-receipt.ts +15 -0
- package/src/knowledge/task-continuity.ts +413 -0
- package/src/knowledge/workset.ts +77 -0
- package/src/llm/provider.ts +2 -1
- package/src/memory/graph.ts +10 -8
- package/src/memory/session.ts +1 -1
- package/src/orchestrate/coordinator.ts +60 -0
- package/src/orchestrate/effect-ledger.ts +160 -0
- package/src/orchestrate/pipeline-trace.ts +40 -6
- package/src/runtime/maintenance-jobs.ts +12 -6
- package/src/sdk.ts +24 -0
- package/src/server/mcp-binding-store.ts +14 -3
- package/src/server/modern-mcp-bridge.ts +153 -27
- package/src/server/tool-profile.ts +3 -2
- package/src/server.ts +131 -7
- 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 +150 -7
- 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/team/team-store.ts +62 -27
- 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/team/team-store.ts
CHANGED
|
@@ -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
|
|
478
|
+
listAgents(projectId: string, filter?: { status?: 'active' | 'inactive'; limit?: number }): TeamAgentRow[] {
|
|
479
|
+
const limit = clampTeamQueryLimit(filter?.limit);
|
|
472
480
|
if (filter?.status) {
|
|
473
|
-
return
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
813
|
-
|
|
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
|
-
|
|
838
|
+
clauses.push('status = ?');
|
|
839
|
+
values.push(filter.status);
|
|
818
840
|
}
|
|
819
841
|
if (filter?.assignee) {
|
|
820
|
-
|
|
842
|
+
clauses.push('assignee_agent_id = ?');
|
|
843
|
+
values.push(filter.assignee);
|
|
821
844
|
}
|
|
822
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|