aidimag 1.0.0
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/LICENSE +102 -0
- package/README.md +113 -0
- package/dist/capture/bootstrap.d.ts +25 -0
- package/dist/capture/bootstrap.js +188 -0
- package/dist/capture/commit-miner.d.ts +59 -0
- package/dist/capture/commit-miner.js +381 -0
- package/dist/capture/harvest.d.ts +50 -0
- package/dist/capture/harvest.js +207 -0
- package/dist/capture/pr-miner.d.ts +57 -0
- package/dist/capture/pr-miner.js +185 -0
- package/dist/capture/session-briefing.d.ts +36 -0
- package/dist/capture/session-briefing.js +150 -0
- package/dist/capture/session-extraction.d.ts +23 -0
- package/dist/capture/session-extraction.js +54 -0
- package/dist/capture/triage.d.ts +30 -0
- package/dist/capture/triage.js +108 -0
- package/dist/cli/commands/capture.d.ts +5 -0
- package/dist/cli/commands/capture.js +357 -0
- package/dist/cli/commands/hosts.d.ts +5 -0
- package/dist/cli/commands/hosts.js +98 -0
- package/dist/cli/commands/knowledge.d.ts +5 -0
- package/dist/cli/commands/knowledge.js +121 -0
- package/dist/cli/commands/memory.d.ts +6 -0
- package/dist/cli/commands/memory.js +392 -0
- package/dist/cli/commands/sync.d.ts +5 -0
- package/dist/cli/commands/sync.js +307 -0
- package/dist/cli/commands/tickets.d.ts +6 -0
- package/dist/cli/commands/tickets.js +328 -0
- package/dist/cli/commands/verify.d.ts +5 -0
- package/dist/cli/commands/verify.js +136 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.js +46 -0
- package/dist/cli/shared.d.ts +37 -0
- package/dist/cli/shared.js +175 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.js +51 -0
- package/dist/context/generate.d.ts +24 -0
- package/dist/context/generate.js +115 -0
- package/dist/critique/critique.d.ts +40 -0
- package/dist/critique/critique.js +110 -0
- package/dist/db/schema.d.ts +28 -0
- package/dist/db/schema.js +269 -0
- package/dist/db/store.d.ts +182 -0
- package/dist/db/store.js +906 -0
- package/dist/debug.d.ts +14 -0
- package/dist/debug.js +25 -0
- package/dist/embeddings/provider.d.ts +16 -0
- package/dist/embeddings/provider.js +100 -0
- package/dist/embeddings/search.d.ts +22 -0
- package/dist/embeddings/search.js +95 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/knowledge/chunk.d.ts +9 -0
- package/dist/knowledge/chunk.js +78 -0
- package/dist/knowledge/extract.d.ts +34 -0
- package/dist/knowledge/extract.js +113 -0
- package/dist/knowledge/ingest.d.ts +79 -0
- package/dist/knowledge/ingest.js +305 -0
- package/dist/knowledge/llm.d.ts +21 -0
- package/dist/knowledge/llm.js +110 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +532 -0
- package/dist/security/evidence.d.ts +11 -0
- package/dist/security/evidence.js +15 -0
- package/dist/security/seal.d.ts +3 -0
- package/dist/security/seal.js +28 -0
- package/dist/security/sync-push.d.ts +2 -0
- package/dist/security/sync-push.js +37 -0
- package/dist/security/url.d.ts +6 -0
- package/dist/security/url.js +67 -0
- package/dist/sync/client.d.ts +84 -0
- package/dist/sync/client.js +391 -0
- package/dist/sync/server.d.ts +75 -0
- package/dist/sync/server.js +659 -0
- package/dist/tickets/provider.d.ts +80 -0
- package/dist/tickets/provider.js +375 -0
- package/dist/types.d.ts +133 -0
- package/dist/types.js +5 -0
- package/dist/ui/page.d.ts +5 -0
- package/dist/ui/page.js +841 -0
- package/dist/ui/server.d.ts +10 -0
- package/dist/ui/server.js +437 -0
- package/dist/verify/check.d.ts +38 -0
- package/dist/verify/check.js +121 -0
- package/dist/verify/engine.d.ts +39 -0
- package/dist/verify/engine.js +178 -0
- package/dist/verify/hooks.d.ts +24 -0
- package/dist/verify/hooks.js +125 -0
- package/dist/verify/runners.d.ts +26 -0
- package/dist/verify/runners.js +193 -0
- package/package.json +78 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
/** Block SSRF to private/link-local/metadata hosts when fetching user-supplied URLs. */
|
|
3
|
+
export function isBlockedFetchHost(hostname) {
|
|
4
|
+
const host = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
5
|
+
if (host === "localhost" || host.endsWith(".localhost"))
|
|
6
|
+
return true;
|
|
7
|
+
if (host.endsWith(".local"))
|
|
8
|
+
return true;
|
|
9
|
+
const ipVer = isIP(host);
|
|
10
|
+
if (ipVer === 4) {
|
|
11
|
+
const [a, b] = host.split(".").map(Number);
|
|
12
|
+
if (a === 10)
|
|
13
|
+
return true;
|
|
14
|
+
if (a === 127)
|
|
15
|
+
return true;
|
|
16
|
+
if (a === 0)
|
|
17
|
+
return true;
|
|
18
|
+
if (a === 169 && b === 254)
|
|
19
|
+
return true;
|
|
20
|
+
if (a === 192 && b === 168)
|
|
21
|
+
return true;
|
|
22
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
23
|
+
return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (ipVer === 6) {
|
|
27
|
+
if (host === "::1")
|
|
28
|
+
return true;
|
|
29
|
+
if (host.startsWith("fc") || host.startsWith("fd"))
|
|
30
|
+
return true;
|
|
31
|
+
if (host.startsWith("fe80"))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/** Validate http(s) ticket provider base URLs before server-side fetch. */
|
|
37
|
+
export function isAllowedTicketBaseUrl(raw) {
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(raw);
|
|
40
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
41
|
+
return false;
|
|
42
|
+
if (u.username || u.password)
|
|
43
|
+
return false;
|
|
44
|
+
return !isBlockedFetchHost(u.hostname);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Validate cloud/sync server URLs before storing from the local UI. */
|
|
51
|
+
export function isAllowedSyncServerUrl(raw) {
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(raw);
|
|
54
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
55
|
+
return false;
|
|
56
|
+
if (u.username || u.password)
|
|
57
|
+
return false;
|
|
58
|
+
const host = u.hostname.toLowerCase();
|
|
59
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1")
|
|
60
|
+
return true;
|
|
61
|
+
return !isBlockedFetchHost(host);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=url.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync client (Phase 6) — `dim cloud link` + `dim sync`.
|
|
3
|
+
*
|
|
4
|
+
* Local-first LWW sync:
|
|
5
|
+
* push: rows changed since last push (+ tombstones) → server
|
|
6
|
+
* pull: latest remote rows since cursor → apply if remote.updatedAt > local
|
|
7
|
+
*
|
|
8
|
+
* Config split (by design):
|
|
9
|
+
* .aidimag/config.json { server, brain } — committed to git (no secrets)
|
|
10
|
+
* ~/.aidimag/credentials.json { [server]: token } — never in the repo
|
|
11
|
+
*/
|
|
12
|
+
import type { MemoryStore } from "../db/store.js";
|
|
13
|
+
import type { RemoteSnapshot } from "./server.js";
|
|
14
|
+
export interface CloudConfig {
|
|
15
|
+
server: string;
|
|
16
|
+
brain: string;
|
|
17
|
+
}
|
|
18
|
+
/** Scope sync cursors per brain so linking a new cloud project triggers a fresh upload. */
|
|
19
|
+
export declare function syncMetaKey(base: string, brain: string): string;
|
|
20
|
+
export declare function configPath(repoRoot: string): string;
|
|
21
|
+
export declare function readCloudConfig(repoRoot: string): CloudConfig | null;
|
|
22
|
+
export declare function writeCloudConfig(repoRoot: string, cfg: CloudConfig): void;
|
|
23
|
+
export declare function getToken(server: string): string | null;
|
|
24
|
+
export declare function saveToken(server: string, token: string): void;
|
|
25
|
+
export declare function removeToken(server: string): boolean;
|
|
26
|
+
export interface DeviceStart {
|
|
27
|
+
device_code: string;
|
|
28
|
+
user_code: string;
|
|
29
|
+
verification_uri: string;
|
|
30
|
+
interval: number;
|
|
31
|
+
expires_in: number;
|
|
32
|
+
}
|
|
33
|
+
/** Begin a device-code login: server hands back a user code + approval URL. */
|
|
34
|
+
export declare function startDeviceLogin(server: string): Promise<DeviceStart>;
|
|
35
|
+
/** Poll until the device is approved in the browser. Saves the token on success. */
|
|
36
|
+
export declare function pollDeviceLogin(server: string, start: DeviceStart): Promise<{
|
|
37
|
+
token: string;
|
|
38
|
+
brain: string | null;
|
|
39
|
+
}>;
|
|
40
|
+
export interface SyncResult {
|
|
41
|
+
pushed: number;
|
|
42
|
+
/** Memory rows accepted by the server this run (excludes proposals/tombstones). */
|
|
43
|
+
memoriesPushed: number;
|
|
44
|
+
/** Memory rows in the push batch this run (excludes proposals/tombstones). */
|
|
45
|
+
memoriesQueued: number;
|
|
46
|
+
pulled: number;
|
|
47
|
+
applied: number;
|
|
48
|
+
skippedOlder: number;
|
|
49
|
+
/** lifecycle events shipped to the server (consensus input) */
|
|
50
|
+
eventsPushed: number;
|
|
51
|
+
/** Local rows considered for push this run. */
|
|
52
|
+
pushQueued: number;
|
|
53
|
+
/** Local memories exist but incremental push sent nothing (remote may already have data). */
|
|
54
|
+
pushSkipped: boolean;
|
|
55
|
+
/** Remote brain is empty while local has memories — user was not asked or declined upload. */
|
|
56
|
+
needsFullUploadConfirm?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface SyncOptions {
|
|
59
|
+
/** Push every local memory/proposal, not just rows changed since the last push. */
|
|
60
|
+
full?: boolean;
|
|
61
|
+
/** Called when local has memories missing on the remote; return true to run a full upload. */
|
|
62
|
+
confirmFullUpload?: (localMemoryCount: number, remoteMemoryCount: number | null) => Promise<boolean>;
|
|
63
|
+
}
|
|
64
|
+
export type { RemoteSnapshot, RemoteSnapshotItem } from "./server.js";
|
|
65
|
+
export interface FetchRemoteSnapshotOpts {
|
|
66
|
+
id?: string;
|
|
67
|
+
tbl?: "memories" | "proposals";
|
|
68
|
+
limit?: number;
|
|
69
|
+
/** When false, return counts/seq only. Default true unless fetching by id. */
|
|
70
|
+
list?: boolean;
|
|
71
|
+
full?: boolean;
|
|
72
|
+
/** When listing proposals, include resolved (APPROVED/REJECTED) rows. Default: pending only. */
|
|
73
|
+
all?: boolean;
|
|
74
|
+
}
|
|
75
|
+
/** Read the server's current latest-state snapshot without pulling into local DB. */
|
|
76
|
+
export declare function fetchRemoteSnapshot(repoRoot: string, opts?: FetchRemoteSnapshotOpts): Promise<RemoteSnapshot>;
|
|
77
|
+
export declare function sync(store: MemoryStore, repoRoot: string, opts?: SyncOptions): Promise<SyncResult>;
|
|
78
|
+
/**
|
|
79
|
+
* Best-effort background sync after local mutations (CLOUD_DESIGN: "runs
|
|
80
|
+
* automatically (debounced) after remember / review / verify").
|
|
81
|
+
* No-ops when the repo isn't cloud-linked, no token is stored, the last
|
|
82
|
+
* auto-sync was <30s ago, or AIDIMAG_AUTO_SYNC=off. Never throws.
|
|
83
|
+
*/
|
|
84
|
+
export declare function maybeAutoSync(store: MemoryStore, repoRoot: string): Promise<SyncResult | null>;
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync client (Phase 6) — `dim cloud link` + `dim sync`.
|
|
3
|
+
*
|
|
4
|
+
* Local-first LWW sync:
|
|
5
|
+
* push: rows changed since last push (+ tombstones) → server
|
|
6
|
+
* pull: latest remote rows since cursor → apply if remote.updatedAt > local
|
|
7
|
+
*
|
|
8
|
+
* Config split (by design):
|
|
9
|
+
* .aidimag/config.json { server, brain } — committed to git (no secrets)
|
|
10
|
+
* ~/.aidimag/credentials.json { [server]: token } — never in the repo
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { debugLog } from "../debug.js";
|
|
16
|
+
const CURSOR_KEY = "sync_pull_cursor";
|
|
17
|
+
const LAST_PUSH_KEY = "sync_last_push_at";
|
|
18
|
+
/** Scope sync cursors per brain so linking a new cloud project triggers a fresh upload. */
|
|
19
|
+
export function syncMetaKey(base, brain) {
|
|
20
|
+
return `${base}:${brain}`;
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------- config & credentials
|
|
23
|
+
export function configPath(repoRoot) {
|
|
24
|
+
return path.join(repoRoot, ".aidimag", "config.json");
|
|
25
|
+
}
|
|
26
|
+
export function readCloudConfig(repoRoot) {
|
|
27
|
+
const p = configPath(repoRoot);
|
|
28
|
+
if (!existsSync(p))
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
const cfg = JSON.parse(readFileSync(p, "utf8"));
|
|
32
|
+
return cfg.server && cfg.brain ? { server: cfg.server, brain: cfg.brain } : null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function writeCloudConfig(repoRoot, cfg) {
|
|
39
|
+
mkdirSync(path.join(repoRoot, ".aidimag"), { recursive: true });
|
|
40
|
+
// merge: config.json also carries the tickets section etc — never clobber
|
|
41
|
+
const p = configPath(repoRoot);
|
|
42
|
+
let existing = {};
|
|
43
|
+
try {
|
|
44
|
+
existing = JSON.parse(readFileSync(p, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// fresh file
|
|
48
|
+
}
|
|
49
|
+
writeFileSync(p, JSON.stringify({ ...existing, server: cfg.server, brain: cfg.brain }, null, 2) + "\n");
|
|
50
|
+
}
|
|
51
|
+
function credentialsPath() {
|
|
52
|
+
return path.join(homedir(), ".aidimag", "credentials.json");
|
|
53
|
+
}
|
|
54
|
+
export function getToken(server) {
|
|
55
|
+
if (process.env.AIDIMAG_API_KEY)
|
|
56
|
+
return process.env.AIDIMAG_API_KEY;
|
|
57
|
+
const p = credentialsPath();
|
|
58
|
+
if (!existsSync(p))
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(p, "utf8"))[server] ?? null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function saveToken(server, token) {
|
|
68
|
+
const p = credentialsPath();
|
|
69
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
70
|
+
const creds = existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : {};
|
|
71
|
+
creds[server] = token;
|
|
72
|
+
writeFileSync(p, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
73
|
+
try {
|
|
74
|
+
chmodSync(p, 0o600);
|
|
75
|
+
}
|
|
76
|
+
catch { /* best-effort on Windows */ }
|
|
77
|
+
}
|
|
78
|
+
export function removeToken(server) {
|
|
79
|
+
const p = credentialsPath();
|
|
80
|
+
if (!existsSync(p))
|
|
81
|
+
return false;
|
|
82
|
+
const creds = JSON.parse(readFileSync(p, "utf8"));
|
|
83
|
+
if (!(server in creds))
|
|
84
|
+
return false;
|
|
85
|
+
delete creds[server];
|
|
86
|
+
writeFileSync(p, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/** Begin a device-code login: server hands back a user code + approval URL. */
|
|
90
|
+
export async function startDeviceLogin(server) {
|
|
91
|
+
const res = await cloudFetch(server, `${server}/v1/auth/device`, { method: "POST" });
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
throw new Error(res.status === 404
|
|
94
|
+
? `server does not support device login (upgrade it to this aidimag version)`
|
|
95
|
+
: `device login: HTTP ${res.status} ${await res.text()}`);
|
|
96
|
+
}
|
|
97
|
+
return (await res.json());
|
|
98
|
+
}
|
|
99
|
+
/** Poll until the device is approved in the browser. Saves the token on success. */
|
|
100
|
+
export async function pollDeviceLogin(server, start) {
|
|
101
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
102
|
+
for (;;) {
|
|
103
|
+
if (Date.now() > deadline)
|
|
104
|
+
throw new Error("login timed out — run `dim login` again");
|
|
105
|
+
await new Promise((r) => setTimeout(r, start.interval * 1000));
|
|
106
|
+
const res = await cloudFetch(server, `${server}/v1/auth/token`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify({ device_code: start.device_code }),
|
|
110
|
+
});
|
|
111
|
+
if (res.status === 428)
|
|
112
|
+
continue; // authorization_pending
|
|
113
|
+
if (!res.ok)
|
|
114
|
+
throw new Error(`login failed: HTTP ${res.status} ${await res.text()}`);
|
|
115
|
+
const out = (await res.json());
|
|
116
|
+
saveToken(server, out.token);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function formatFetchError(server, err) {
|
|
121
|
+
if (err instanceof TypeError && err.message === "fetch failed") {
|
|
122
|
+
const cause = err.cause;
|
|
123
|
+
const detail = cause?.code ?? cause?.message ?? "network error";
|
|
124
|
+
if (detail === "ECONNREFUSED" || /ECONNREFUSED/i.test(String(detail))) {
|
|
125
|
+
return new Error(`cannot reach sync server at ${server} (connection refused). ` +
|
|
126
|
+
`For aidimag-cloud dev, run \`npm run dev\` in aidimag-cloud and link \`http://localhost:3000\`. ` +
|
|
127
|
+
`For self-hosted sync, run \`dim serve\` (default port 8787). Check \`dim cloud status\`.`);
|
|
128
|
+
}
|
|
129
|
+
return new Error(`cannot reach sync server at ${server}: ${detail}`);
|
|
130
|
+
}
|
|
131
|
+
if (err instanceof Error)
|
|
132
|
+
return err;
|
|
133
|
+
return new Error(String(err));
|
|
134
|
+
}
|
|
135
|
+
async function cloudFetch(server, url, init) {
|
|
136
|
+
try {
|
|
137
|
+
return await fetch(url, init);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
throw formatFetchError(server, err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function api(cfg, token, pathAndQuery, init) {
|
|
144
|
+
const res = await cloudFetch(cfg.server, `${cfg.server}${pathAndQuery}`, {
|
|
145
|
+
...init,
|
|
146
|
+
headers: {
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
Authorization: `Bearer ${token}`,
|
|
149
|
+
...(init?.headers ?? {}),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
if (!res.ok)
|
|
153
|
+
throw new Error(`sync server ${pathAndQuery}: HTTP ${res.status} ${await res.text()}`);
|
|
154
|
+
return (await res.json());
|
|
155
|
+
}
|
|
156
|
+
/** Read the server's current latest-state snapshot without pulling into local DB. */
|
|
157
|
+
export async function fetchRemoteSnapshot(repoRoot, opts = {}) {
|
|
158
|
+
const cfg = readCloudConfig(repoRoot);
|
|
159
|
+
if (!cfg)
|
|
160
|
+
throw new Error("repo is not cloud-linked. Run `dim cloud link --server <url> --brain <name> --token <token>` first.");
|
|
161
|
+
const token = getToken(cfg.server);
|
|
162
|
+
if (!token)
|
|
163
|
+
throw new Error(`no credentials for ${cfg.server}. Run \`dim cloud link\` with --token, or set AIDIMAG_API_KEY.`);
|
|
164
|
+
const q = new URLSearchParams({ brain: cfg.brain });
|
|
165
|
+
if (opts.id)
|
|
166
|
+
q.set("id", opts.id);
|
|
167
|
+
if (opts.tbl)
|
|
168
|
+
q.set("tbl", opts.tbl);
|
|
169
|
+
if (opts.limit != null)
|
|
170
|
+
q.set("limit", String(opts.limit));
|
|
171
|
+
if (opts.list === false)
|
|
172
|
+
q.set("list", "0");
|
|
173
|
+
if (opts.full)
|
|
174
|
+
q.set("full", "1");
|
|
175
|
+
if (opts.all)
|
|
176
|
+
q.set("all", "1");
|
|
177
|
+
return api(cfg, token, `/v1/snapshot?${q.toString()}`);
|
|
178
|
+
}
|
|
179
|
+
/** Remote memory count for this brain, or null if the server could not be queried. */
|
|
180
|
+
async function getRemoteMemoryCount(cfg, token) {
|
|
181
|
+
try {
|
|
182
|
+
const remote = await api(cfg, token, `/v1/snapshot?brain=${encodeURIComponent(cfg.brain)}&list=0`);
|
|
183
|
+
return remote.counts.memories;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (!(err instanceof Error && /HTTP 404/.test(err.message)))
|
|
187
|
+
throw err;
|
|
188
|
+
// Older servers without /v1/snapshot — infer from pull.
|
|
189
|
+
try {
|
|
190
|
+
const pull = await api(cfg, token, `/v1/pull?brain=${encodeURIComponent(cfg.brain)}&since=0`);
|
|
191
|
+
return pull.items.filter((i) => i.tbl === "memories" && !i.deleted).length;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export async function sync(store, repoRoot, opts = {}) {
|
|
199
|
+
const cfg = readCloudConfig(repoRoot);
|
|
200
|
+
if (!cfg)
|
|
201
|
+
throw new Error("repo is not cloud-linked. Run `dim cloud link --server <url> --brain <name> --token <token>` first.");
|
|
202
|
+
const token = getToken(cfg.server);
|
|
203
|
+
if (!token)
|
|
204
|
+
throw new Error(`no credentials for ${cfg.server}. Run \`dim cloud link\` with --token, or set AIDIMAG_API_KEY.`);
|
|
205
|
+
const cursorKey = syncMetaKey(CURSOR_KEY, cfg.brain);
|
|
206
|
+
const lastPushKey = syncMetaKey(LAST_PUSH_KEY, cfg.brain);
|
|
207
|
+
const localMemoryCount = store.statusSummary().total;
|
|
208
|
+
// ---- push
|
|
209
|
+
const lastPush = opts.full ? null : store.getMeta(lastPushKey);
|
|
210
|
+
const changes = store.changedSince(lastPush);
|
|
211
|
+
const items = [
|
|
212
|
+
...changes.memories.map((m) => ({
|
|
213
|
+
tbl: "memories",
|
|
214
|
+
id: m.id,
|
|
215
|
+
updatedAt: m.updatedAt ?? m.createdAt,
|
|
216
|
+
deleted: false,
|
|
217
|
+
payload: m,
|
|
218
|
+
})),
|
|
219
|
+
...changes.proposals.map((p) => ({
|
|
220
|
+
tbl: "proposals",
|
|
221
|
+
id: p.id,
|
|
222
|
+
updatedAt: p.updatedAt ?? p.createdAt,
|
|
223
|
+
deleted: false,
|
|
224
|
+
payload: p,
|
|
225
|
+
})),
|
|
226
|
+
...changes.tombstones.map((t) => ({
|
|
227
|
+
tbl: t.tbl,
|
|
228
|
+
id: t.id,
|
|
229
|
+
updatedAt: t.deletedAt,
|
|
230
|
+
deleted: true,
|
|
231
|
+
payload: null,
|
|
232
|
+
})),
|
|
233
|
+
];
|
|
234
|
+
let uploadGapDetected = false;
|
|
235
|
+
let remoteMemoryCount = null;
|
|
236
|
+
// Incremental push is empty but local still has memories not reflected on the remote.
|
|
237
|
+
if (!opts.full && items.length === 0 && localMemoryCount > 0) {
|
|
238
|
+
remoteMemoryCount = await getRemoteMemoryCount(cfg, token);
|
|
239
|
+
const missingOnRemote = remoteMemoryCount === null || remoteMemoryCount < localMemoryCount;
|
|
240
|
+
if (missingOnRemote) {
|
|
241
|
+
uploadGapDetected = true;
|
|
242
|
+
if (opts.confirmFullUpload) {
|
|
243
|
+
const ok = await opts.confirmFullUpload(localMemoryCount, remoteMemoryCount);
|
|
244
|
+
if (ok) {
|
|
245
|
+
store.setMeta(cursorKey, "0");
|
|
246
|
+
return sync(store, repoRoot, { full: true });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
let pushed = 0;
|
|
252
|
+
let memoriesPushed = 0;
|
|
253
|
+
const pushQueued = items.length;
|
|
254
|
+
const memoriesQueued = items.filter((it) => it.tbl === "memories" && !it.deleted).length;
|
|
255
|
+
if (items.length > 0) {
|
|
256
|
+
const r = await api(cfg, token, `/v1/push?brain=${encodeURIComponent(cfg.brain)}`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
body: JSON.stringify({ items }),
|
|
259
|
+
});
|
|
260
|
+
pushed = r.accepted;
|
|
261
|
+
memoriesPushed =
|
|
262
|
+
r.memoriesAccepted ??
|
|
263
|
+
(pushed === 0 ? 0 : pushed === pushQueued ? memoriesQueued : Math.min(memoriesQueued, pushed));
|
|
264
|
+
}
|
|
265
|
+
if (pushQueued > 0) {
|
|
266
|
+
const latestItemAt = items.reduce((max, it) => (!max || it.updatedAt > max ? it.updatedAt : max), "");
|
|
267
|
+
store.setMeta(lastPushKey, latestItemAt || new Date().toISOString());
|
|
268
|
+
}
|
|
269
|
+
// Warn only when incremental sent nothing and remote still appears to be missing local data.
|
|
270
|
+
const pushSkipped = !opts.full &&
|
|
271
|
+
pushQueued === 0 &&
|
|
272
|
+
localMemoryCount > 0 &&
|
|
273
|
+
!uploadGapDetected &&
|
|
274
|
+
(remoteMemoryCount === null || remoteMemoryCount < localMemoryCount);
|
|
275
|
+
const needsFullUploadConfirm = uploadGapDetected && pushQueued === 0 && !opts.full;
|
|
276
|
+
// ---- push events (append-only lifecycle log → server-side consensus)
|
|
277
|
+
let eventsPushed = 0;
|
|
278
|
+
try {
|
|
279
|
+
for (;;) {
|
|
280
|
+
const batch = store.unsyncedEvents(500);
|
|
281
|
+
if (!batch.length)
|
|
282
|
+
break;
|
|
283
|
+
const events = batch.map((e) => ({
|
|
284
|
+
id: e.id,
|
|
285
|
+
type: e.type,
|
|
286
|
+
memoryId: e.memoryId,
|
|
287
|
+
payload: e.payload,
|
|
288
|
+
machine: e.machine,
|
|
289
|
+
schemaVersion: e.schemaVersion,
|
|
290
|
+
createdAt: e.createdAt,
|
|
291
|
+
}));
|
|
292
|
+
await api(cfg, token, `/v1/events?brain=${encodeURIComponent(cfg.brain)}`, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
body: JSON.stringify({ events }),
|
|
295
|
+
});
|
|
296
|
+
store.markEventsSynced(batch.map((e) => e.seq));
|
|
297
|
+
eventsPushed += batch.length;
|
|
298
|
+
if (batch.length < 500)
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
// older server without /v1/events — events stay queued locally, retry next sync
|
|
304
|
+
if (!(err instanceof Error && /HTTP 404/.test(err.message)))
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
// ---- pull
|
|
308
|
+
const cursor = parseInt(store.getMeta(cursorKey) ?? "0", 10);
|
|
309
|
+
const pullRes = await api(cfg, token, `/v1/pull?brain=${encodeURIComponent(cfg.brain)}&since=${cursor}`);
|
|
310
|
+
let applied = 0;
|
|
311
|
+
let skippedOlder = 0;
|
|
312
|
+
for (const it of pullRes.items) {
|
|
313
|
+
if (it.deleted) {
|
|
314
|
+
const localUpdated = it.tbl === "memories" ? store.memoryUpdatedAt(it.id) : store.proposalUpdatedAt(it.id);
|
|
315
|
+
if (localUpdated !== null && localUpdated > it.updatedAt) {
|
|
316
|
+
skippedOlder++; // local resurrection is newer than remote delete
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
store.applyRemoteTombstone(it.id, it.tbl, it.updatedAt);
|
|
320
|
+
applied++;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (store.isTombstoned(it.id, it.tbl)) {
|
|
324
|
+
// locally deleted; only resurrect if the remote edit is newer than our delete — handled above via push
|
|
325
|
+
skippedOlder++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (it.tbl === "memories") {
|
|
329
|
+
const local = store.memoryUpdatedAt(it.id);
|
|
330
|
+
if (local !== null && local >= it.updatedAt) {
|
|
331
|
+
skippedOlder++;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
store.applyRemoteMemory(it.payload);
|
|
335
|
+
applied++;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const local = store.proposalUpdatedAt(it.id);
|
|
339
|
+
if (local !== null && local >= it.updatedAt) {
|
|
340
|
+
skippedOlder++;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
store.applyRemoteProposal(it.payload);
|
|
344
|
+
applied++;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
store.setMeta(cursorKey, String(pullRes.seq));
|
|
348
|
+
return {
|
|
349
|
+
pushed,
|
|
350
|
+
memoriesPushed,
|
|
351
|
+
memoriesQueued,
|
|
352
|
+
pulled: pullRes.items.length,
|
|
353
|
+
applied,
|
|
354
|
+
skippedOlder,
|
|
355
|
+
eventsPushed,
|
|
356
|
+
pushQueued,
|
|
357
|
+
pushSkipped,
|
|
358
|
+
...(needsFullUploadConfirm ? { needsFullUploadConfirm: true } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// ---------------------------------------------------------------- auto-sync (debounced)
|
|
362
|
+
const AUTO_SYNC_DEBOUNCE_MS = 30_000;
|
|
363
|
+
const AUTO_SYNC_LAST_KEY = "sync_last_auto_at";
|
|
364
|
+
/**
|
|
365
|
+
* Best-effort background sync after local mutations (CLOUD_DESIGN: "runs
|
|
366
|
+
* automatically (debounced) after remember / review / verify").
|
|
367
|
+
* No-ops when the repo isn't cloud-linked, no token is stored, the last
|
|
368
|
+
* auto-sync was <30s ago, or AIDIMAG_AUTO_SYNC=off. Never throws.
|
|
369
|
+
*/
|
|
370
|
+
export async function maybeAutoSync(store, repoRoot) {
|
|
371
|
+
if ((process.env.AIDIMAG_AUTO_SYNC ?? "").toLowerCase() === "off")
|
|
372
|
+
return null;
|
|
373
|
+
const cfg = readCloudConfig(repoRoot);
|
|
374
|
+
if (!cfg)
|
|
375
|
+
return null;
|
|
376
|
+
if (!getToken(cfg.server))
|
|
377
|
+
return null;
|
|
378
|
+
const last = store.getMeta(AUTO_SYNC_LAST_KEY);
|
|
379
|
+
if (last && Date.now() - new Date(last).getTime() < AUTO_SYNC_DEBOUNCE_MS)
|
|
380
|
+
return null;
|
|
381
|
+
try {
|
|
382
|
+
const r = await sync(store, repoRoot);
|
|
383
|
+
store.setMeta(AUTO_SYNC_LAST_KEY, new Date().toISOString());
|
|
384
|
+
return r;
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
debugLog("auto-sync", err);
|
|
388
|
+
return null; // offline / server down — local-first means we just carry on
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aidimag sync server — `dim serve` (Phase 6, self-hostable team mode).
|
|
3
|
+
*
|
|
4
|
+
* Zero extra dependencies: node:http + better-sqlite3. The hosted SaaS later
|
|
5
|
+
* wraps this same protocol with OAuth/billing; teams can always self-host.
|
|
6
|
+
*
|
|
7
|
+
* Protocol (all JSON, Bearer token auth):
|
|
8
|
+
* POST /v1/push?brain=<id> { items: SyncItem[] } → { accepted, seq }
|
|
9
|
+
* GET /v1/pull?brain=<id>&since=<seq> → { items, seq }
|
|
10
|
+
* POST /v1/events?brain=<id> { events: EventItem[] } → { accepted }
|
|
11
|
+
* GET /v1/consensus?brain=<id>[&memory=<id>] → { consensus }
|
|
12
|
+
* GET /v1/snapshot?brain=<id>[&id=][&list=0][&limit=] → { seq, counts, items? }
|
|
13
|
+
* GET /v1/health → { ok, brains }
|
|
14
|
+
*
|
|
15
|
+
* Device auth (SaaS groundwork — RFC 8628-style device flow, no external IdP):
|
|
16
|
+
* POST /v1/auth/device → { device_code, user_code, verification_uri, interval, expires_in }
|
|
17
|
+
* GET /v1/auth/approve → browser approval page
|
|
18
|
+
* POST /v1/auth/approve (form) → approve a user code with an existing credential
|
|
19
|
+
* POST /v1/auth/token { device_code } → pending | { token, brain }
|
|
20
|
+
* The minted account token (aidimag_at_…) inherits the scope of the approving
|
|
21
|
+
* credential: admin token → all brains; member key → that key's brain. The
|
|
22
|
+
* hosted SaaS swaps the approval page for GitHub OAuth — same endpoints.
|
|
23
|
+
*
|
|
24
|
+
* SyncItem = { tbl: "memories"|"proposals", id, updatedAt, deleted, payload }
|
|
25
|
+
* The server is a dumb ordered log + latest-state index per brain. All merge
|
|
26
|
+
* logic (LWW, verification arbitration) lives in clients. Events are an
|
|
27
|
+
* append-only ingest used for cross-machine verification consensus.
|
|
28
|
+
*/
|
|
29
|
+
export interface SyncItem {
|
|
30
|
+
tbl: "memories" | "proposals";
|
|
31
|
+
id: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
deleted: boolean;
|
|
34
|
+
/** full row snapshot (MemoryEntry / Proposal JSON); null when deleted */
|
|
35
|
+
payload: unknown | null;
|
|
36
|
+
}
|
|
37
|
+
export interface EventItem {
|
|
38
|
+
id: string;
|
|
39
|
+
type: string;
|
|
40
|
+
memoryId: string | null;
|
|
41
|
+
payload: Record<string, unknown>;
|
|
42
|
+
machine: string;
|
|
43
|
+
schemaVersion: number;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
}
|
|
46
|
+
/** Current remote state for a brain (GET /v1/snapshot). */
|
|
47
|
+
export interface RemoteSnapshotItem {
|
|
48
|
+
id: string;
|
|
49
|
+
tbl: "memories" | "proposals";
|
|
50
|
+
updatedAt: string;
|
|
51
|
+
deleted: boolean;
|
|
52
|
+
claim?: string;
|
|
53
|
+
status?: string;
|
|
54
|
+
kind?: string;
|
|
55
|
+
payload?: unknown;
|
|
56
|
+
}
|
|
57
|
+
export interface RemoteSnapshot {
|
|
58
|
+
brain: string;
|
|
59
|
+
seq: number;
|
|
60
|
+
counts: {
|
|
61
|
+
memories: number;
|
|
62
|
+
proposals: number;
|
|
63
|
+
tombstones: number;
|
|
64
|
+
};
|
|
65
|
+
items?: RemoteSnapshotItem[];
|
|
66
|
+
item?: RemoteSnapshotItem;
|
|
67
|
+
}
|
|
68
|
+
/** Escape untrusted text before interpolating into the HTML approval page (XSS guard). */
|
|
69
|
+
export declare function escapeHtml(s: string): string;
|
|
70
|
+
export declare function startSyncServer(opts: {
|
|
71
|
+
dbPath: string;
|
|
72
|
+
token: string;
|
|
73
|
+
port?: number;
|
|
74
|
+
host?: string;
|
|
75
|
+
}): Promise<string>;
|