wyrm-mcp 7.2.0 → 7.2.2

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 (156) hide show
  1. package/LICENSE +26 -667
  2. package/NOTICE +14 -33
  3. package/dist/activation.d.ts.map +1 -1
  4. package/dist/activation.js +1 -44
  5. package/dist/activation.js.map +1 -1
  6. package/dist/agent-daemon.js +4 -281
  7. package/dist/agent-loop.js +7 -332
  8. package/dist/analytics.js +13 -236
  9. package/dist/attribution.js +1 -49
  10. package/dist/audit.js +2 -457
  11. package/dist/auto-capture.js +3 -138
  12. package/dist/auto-orchestrator.js +1 -325
  13. package/dist/autoconfig.js +39 -840
  14. package/dist/buddy-runner.js +1 -109
  15. package/dist/buddy.js +14 -564
  16. package/dist/build-flags.js +1 -17
  17. package/dist/capabilities.js +3 -183
  18. package/dist/capture.js +1 -56
  19. package/dist/causality.js +6 -107
  20. package/dist/cli.js +20 -281
  21. package/dist/cloud/cli.js +5 -541
  22. package/dist/cloud/client.js +1 -221
  23. package/dist/cloud/crypto.js +1 -85
  24. package/dist/cloud/machine-id.js +2 -113
  25. package/dist/cloud/recovery.js +1 -60
  26. package/dist/cloud/sync-engine.js +7 -543
  27. package/dist/cloud-backup.js +5 -579
  28. package/dist/cloud-profile.js +1 -138
  29. package/dist/cloud-sync-entrypoint.js +1 -47
  30. package/dist/cloud-sync.js +2 -309
  31. package/dist/constellation.js +12 -168
  32. package/dist/context-build-budgeted.js +4 -144
  33. package/dist/context-ranking.js +1 -69
  34. package/dist/crypto.js +1 -179
  35. package/dist/daemon-write-endpoint.js +1 -290
  36. package/dist/daemon-writer.js +2 -406
  37. package/dist/database.js +43 -1110
  38. package/dist/deprecations.js +2 -162
  39. package/dist/design.js +13 -141
  40. package/dist/event-replication.js +1 -112
  41. package/dist/events-sse.js +7 -43
  42. package/dist/events.js +6 -238
  43. package/dist/failure-patterns.js +42 -659
  44. package/dist/federation.js +12 -236
  45. package/dist/goals.js +13 -101
  46. package/dist/golden.js +3 -355
  47. package/dist/handlers/agent.js +4 -165
  48. package/dist/handlers/alias-adapters.js +1 -129
  49. package/dist/handlers/aliases.js +1 -171
  50. package/dist/handlers/audit.js +1 -87
  51. package/dist/handlers/boundary.js +1 -221
  52. package/dist/handlers/capture.js +73 -1109
  53. package/dist/handlers/causality.js +7 -114
  54. package/dist/handlers/cloud.js +85 -382
  55. package/dist/handlers/companion.js +28 -459
  56. package/dist/handlers/datalake.js +7 -187
  57. package/dist/handlers/dispatch-context.js +0 -22
  58. package/dist/handlers/entity.js +25 -256
  59. package/dist/handlers/events.js +16 -335
  60. package/dist/handlers/failure.js +13 -340
  61. package/dist/handlers/goals.js +4 -296
  62. package/dist/handlers/intelligence.js +126 -674
  63. package/dist/handlers/invoicing.js +1 -70
  64. package/dist/handlers/mcpclient.js +6 -137
  65. package/dist/handlers/orchestration.js +40 -125
  66. package/dist/handlers/output-schemas.js +1 -24
  67. package/dist/handlers/presence.js +3 -99
  68. package/dist/handlers/project.js +28 -182
  69. package/dist/handlers/prompts.js +6 -157
  70. package/dist/handlers/quest.js +4 -224
  71. package/dist/handlers/recall.js +11 -218
  72. package/dist/handlers/registry.js +1 -167
  73. package/dist/handlers/resources.js +1 -288
  74. package/dist/handlers/review.js +11 -74
  75. package/dist/handlers/run.js +17 -487
  76. package/dist/handlers/search.js +15 -326
  77. package/dist/handlers/session.js +28 -615
  78. package/dist/handlers/share.js +8 -184
  79. package/dist/handlers/shims.js +1 -464
  80. package/dist/handlers/skill.js +67 -449
  81. package/dist/handlers/survivors.js +1 -120
  82. package/dist/handlers/symbols.js +8 -109
  83. package/dist/handlers/syncops.js +4 -302
  84. package/dist/handlers/types.js +1 -27
  85. package/dist/harvest.js +5 -191
  86. package/dist/hours.js +7 -156
  87. package/dist/http-auth.js +3 -321
  88. package/dist/http-fast.js +21 -1137
  89. package/dist/icons.js +1 -47
  90. package/dist/index.js +2 -924
  91. package/dist/indexer.js +4 -145
  92. package/dist/intelligence.js +31 -261
  93. package/dist/internal-dispatch.js +3 -212
  94. package/dist/keyset.js +1 -110
  95. package/dist/knowledge-graph.js +12 -176
  96. package/dist/license.d.ts +11 -0
  97. package/dist/license.d.ts.map +1 -1
  98. package/dist/license.js +2 -414
  99. package/dist/license.js.map +1 -1
  100. package/dist/logger.js +2 -199
  101. package/dist/maintenance.js +2 -148
  102. package/dist/mcp-client.js +6 -262
  103. package/dist/memory-artifacts.js +30 -449
  104. package/dist/migrate-prompt.js +2 -124
  105. package/dist/migrations.js +40 -655
  106. package/dist/performance.js +1 -228
  107. package/dist/presence.js +11 -140
  108. package/dist/priority-embed.js +5 -164
  109. package/dist/providers/embedding-provider.js +1 -196
  110. package/dist/readonly-gate.js +1 -29
  111. package/dist/rehydration.js +9 -157
  112. package/dist/reindex.js +1 -88
  113. package/dist/render-target.js +21 -514
  114. package/dist/render.js +4 -280
  115. package/dist/repl-guard.js +1 -173
  116. package/dist/replication-daemon-entrypoint.js +1 -31
  117. package/dist/replication-daemon.js +2 -262
  118. package/dist/resilience.js +1 -591
  119. package/dist/reverse-bridge.js +5 -360
  120. package/dist/security.js +1 -244
  121. package/dist/session-seen.js +3 -51
  122. package/dist/setup.js +1 -260
  123. package/dist/skill-author.js +5 -168
  124. package/dist/spec-kit.js +1 -191
  125. package/dist/sqlite-busy.js +1 -154
  126. package/dist/statusline.js +11 -315
  127. package/dist/sub-agent.js +13 -262
  128. package/dist/summarizer.js +13 -139
  129. package/dist/symbols.js +7 -283
  130. package/dist/sync.js +5 -359
  131. package/dist/tasks-dispatch.js +1 -84
  132. package/dist/tasks.js +1 -282
  133. package/dist/token-budget.js +1 -143
  134. package/dist/tool-analytics.js +7 -129
  135. package/dist/tool-annotations.js +1 -365
  136. package/dist/tool-manifest-v2.json +1 -1
  137. package/dist/tool-manifest.json +1 -1
  138. package/dist/tool-profiles.js +1 -75
  139. package/dist/trace-harvest.js +6 -244
  140. package/dist/types.js +1 -30
  141. package/dist/ui-dashboard.js +41 -50
  142. package/dist/ulid.js +1 -81
  143. package/dist/validate.js +1 -129
  144. package/dist/vault.js +1 -534
  145. package/dist/vectors.js +3 -184
  146. package/dist/version-check.js +4 -136
  147. package/dist/visibility.js +19 -155
  148. package/dist/wyrm-cli.js +98 -2451
  149. package/dist/wyrm-cli.js.map +1 -1
  150. package/dist/wyrm-guard.js +14 -424
  151. package/dist/wyrm-loop.js +3 -150
  152. package/dist/wyrm-manifest.json +1 -1
  153. package/dist/wyrm-statusline-daemon.js +1 -11
  154. package/dist/wyrm-statusline.js +4 -56
  155. package/dist/wyrm-ui.js +9 -77
  156. package/package.json +4 -2
@@ -1,176 +1,5 @@
1
- /**
2
- * wyrm cloud sync the change-detect + push + pull + merge engine.
3
- *
4
- * v1.1 semantics (incremental + tombstones):
5
- * - PUSH: rows with cross_project_visibility != 'within' AND
6
- * updated_at > last_push_updated_at_ms get serialised + encrypted +
7
- * uploaded. Plus any pending tombstones from sync_tombstones.
8
- * - PULL: fetch peer deltas since pull_cursor. Decrypt. kind='tombstone'
9
- * → DELETE locally. All other kinds → INSERT OR REPLACE on (kind, row_id).
10
- * Last-write-wins on conflicts.
11
- * - This is intentionally NOT a CRDT — for v1 we're solving "same operator
12
- * on phone + laptop". v2 (team tier) will add real conflict semantics.
13
- *
14
- * Tables synced in v1:
15
- * - ground_truths (kind='truth')
16
- * - memory_artifacts (kind='memory_artifact')
17
- * - quests (kind='quest')
18
- * - design_tokens (kind='design_token')
19
- * - design_references (kind='design_reference')
20
- *
21
- * Sessions / hour_entries / failure_patterns are intentionally NOT synced
22
- * yet — they're per-device time-series data that doesn't merge cleanly.
23
- *
24
- * @copyright 2026 Ghost Protocol (Pvt) Ltd.
25
- */
26
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
27
- import { join } from 'node:path';
28
- import { WyrmDB } from '../database.js';
29
- import { CloudClient, loadSession, saveSession, resolveCloudDir } from './client.js';
30
- import { encrypt, decrypt } from './crypto.js';
31
- import { classifySession, computeMachineFp } from './machine-id.js';
32
- /** Resolve the pull-cursor file at call time (honors WYRM_CLOUD_DIR). */
33
- function cursorFilePath() {
34
- return join(resolveCloudDir(), 'cloud-cursor.json');
35
- }
36
- // Default (privacy-first) tables — these have `cross_project_visibility`
37
- // and the operator opts in by setting it to 'org' or 'public'.
38
- export const DEFAULT_TABLES = [
39
- { kind: 'truth', table: 'ground_truths', hasVisibility: true },
40
- { kind: 'memory_artifact', table: 'memory_artifacts', hasVisibility: true },
41
- { kind: 'quest', table: 'quests', hasVisibility: true },
42
- { kind: 'design_token', table: 'design_tokens', hasVisibility: true },
43
- { kind: 'design_reference', table: 'design_references', hasVisibility: true },
44
- // Skills carry the SKILL.md body (migration 25) so they're portable across
45
- // machines. Gated by per-row `cross_project_visibility` (DEFAULT 'within' =
46
- // private) exactly like the rows above — a skill only egresses once the
47
- // operator promotes it (`wyrm skill share <name>`), never by default.
48
- { kind: 'skill', table: 'skills', hasVisibility: true },
49
- ];
50
- /**
51
- * Tables that NEVER sync, even in `--all` mode. These are per-device
52
- * telemetry, derived state, or volatile bookkeeping that doesn't merge
53
- * meaningfully across devices.
54
- */
55
- const DENYLIST_TABLES = new Set([
56
- // Per-device timing / activity
57
- 'sessions', 'agent_actions', 'agent_presence', 'session_seen_artifacts',
58
- // Logs (per-device, append-only, would balloon storage)
59
- 'tool_call_log', 'llm_query_log', 'external_call_log',
60
- 'token_savings_log', 'cost_tracking', 'usage_events', 'audit_log',
61
- // Derived / cache state
62
- 'indexing_queue', 'vectors', 'symbol_index', 'update_check',
63
- // Sync internals — never sync the sync state itself
64
- 'sync_conflicts', 'sync_log', 'sync_tombstones', 'wyrm_cloud_sync_state',
65
- // Local config (machine-specific)
66
- 'mcp_client_configs', 'watch_dirs', 'global_context', 'schema_versions',
67
- // Conservatively skip failure_patterns — per-device debugging context
68
- 'failure_patterns',
69
- ]);
70
- /**
71
- * FTS shadow tables (auto-maintained by SQLite from base tables).
72
- * These get rebuilt from the base table, never sync them directly.
73
- */
74
- const FTS_SHADOW_PATTERN = /_fts(_config|_data|_docsize|_idx)?$/;
75
- /**
76
- * Discover every syncable table in the local DB at runtime.
77
- *
78
- * - Skips FTS shadow tables (auto-derived from base tables)
79
- * - Skips entries in DENYLIST_TABLES (per-device or volatile)
80
- * - Tables WITH `cross_project_visibility` keep `hasVisibility=true`
81
- * so the default opt-in semantics still work for them
82
- * - Tables WITHOUT it get `hasVisibility=false` (synced unconditionally
83
- * in `--all` mode; never in default mode)
84
- *
85
- * The `kind` is derived from the table name (drop trailing 's' for
86
- * common plurals: `skills` → `skill`, `projects` → `project`). This
87
- * keeps the wire format stable even if the local table layout changes.
88
- */
89
- function discoverTables(db) {
90
- const raw = db.getDatabase();
91
- const out = [...DEFAULT_TABLES];
92
- const known = new Set(DEFAULT_TABLES.map((t) => t.table));
93
- try {
94
- const rows = raw.prepare(`SELECT name FROM sqlite_master
95
- WHERE type='table' AND name NOT LIKE 'sqlite_%'`).all();
96
- for (const r of rows) {
97
- const name = r.name;
98
- if (known.has(name))
99
- continue;
100
- if (DENYLIST_TABLES.has(name))
101
- continue;
102
- if (FTS_SHADOW_PATTERN.test(name))
103
- continue;
104
- if (!SAFE_IDENT.test(name))
105
- continue;
106
- // Require an `id` column — primary key for INSERT OR REPLACE
107
- const cols = raw.prepare(`PRAGMA table_info(${name})`).all();
108
- if (!cols.some((c) => c.name === 'id'))
109
- continue;
110
- const hasVis = cols.some((c) => c.name === 'cross_project_visibility');
111
- out.push({ kind: tableToKind(name), table: name, hasVisibility: hasVis });
112
- }
113
- }
114
- catch { /* fall back to defaults */ }
115
- return out;
116
- }
117
- function tableToKind(table) {
118
- // For dynamically-discovered tables, use the table name verbatim as
119
- // the kind. Plural-stripping was buggy (`context` → `contex`,
120
- // `data_lake` → `data_lak`) and the kind is just an opaque routing
121
- // label anyway — round-tripping via the same registry on both sides
122
- // means there's no benefit to "prettifying" it.
123
- //
124
- // DEFAULT_TABLES still uses explicit singular kinds (truth, quest,
125
- // etc.) for backwards-compatibility with deltas already pushed by
126
- // earlier CLI versions.
127
- return table;
128
- }
129
- /**
130
- * Build the inbound-apply column whitelist for a table dynamically by
131
- * reading the local schema. Local schema is trusted (it's our own DB,
132
- * not from peers); the whitelist defends against a malicious peer
133
- * sending crafted column names by ONLY accepting columns that exist
134
- * locally. Anything else is dropped before SQL generation.
135
- */
136
- function columnWhitelist(db, table) {
137
- if (!SAFE_IDENT.test(table))
138
- return new Set();
139
- try {
140
- const raw = db.getDatabase();
141
- const cols = raw.prepare(`PRAGMA table_info(${table})`).all();
142
- return new Set(cols.filter((c) => SAFE_IDENT.test(c.name)).map((c) => c.name));
143
- }
144
- catch {
145
- return new Set();
146
- }
147
- }
148
- // Validation regex for table/column identifiers — defence-in-depth.
149
- const SAFE_IDENT = /^[a-z_][a-z0-9_]*$/i;
150
- // ── Cursor persistence ────────────────────────────────────────────────────
151
- function loadCursor() {
152
- const empty = { pull_cursor: 0, last_push_updated_ms: 0, last_push_at: 0, last_pull_at: 0 };
153
- const file = cursorFilePath();
154
- if (!existsSync(file))
155
- return empty;
156
- try {
157
- const raw = JSON.parse(readFileSync(file, 'utf-8'));
158
- return { ...empty, ...raw };
159
- }
160
- catch {
161
- return empty;
162
- }
163
- }
164
- function saveCursor(c) {
165
- writeFileSync(cursorFilePath(), JSON.stringify(c, null, 2), { mode: 0o600 });
166
- }
167
- // ── Tombstone table ───────────────────────────────────────────────────────
168
- // We keep deletion intents in a local sync_tombstones table. Other code
169
- // paths that DELETE a synced row should also INSERT into sync_tombstones
170
- // (see recordTombstone below). The sync push drains this table.
171
- function ensureTombstoneTable(db) {
172
- const raw = db.getDatabase();
173
- raw.exec(`
1
+ import{readFileSync as O,writeFileSync as k,existsSync as D}from"node:fs";import{join as I}from"node:path";import{WyrmDB as $}from"../database.js";import{CloudClient as C,loadSession as L,saveSession as A,resolveCloudDir as v}from"./client.js";import{encrypt as S,decrypt as j}from"./crypto.js";import{classifySession as x,computeMachineFp as P}from"./machine-id.js";function T(){return I(v(),"cloud-cursor.json")}const b=[{kind:"truth",table:"ground_truths",hasVisibility:!0},{kind:"memory_artifact",table:"memory_artifacts",hasVisibility:!0},{kind:"quest",table:"quests",hasVisibility:!0},{kind:"design_token",table:"design_tokens",hasVisibility:!0},{kind:"design_reference",table:"design_references",hasVisibility:!0},{kind:"skill",table:"skills",hasVisibility:!0}],U=new Set(["sessions","agent_actions","agent_presence","session_seen_artifacts","tool_call_log","llm_query_log","external_call_log","token_savings_log","cost_tracking","usage_events","audit_log","indexing_queue","vectors","symbol_index","update_check","sync_conflicts","sync_log","sync_tombstones","wyrm_cloud_sync_state","mcp_client_configs","watch_dirs","global_context","schema_versions","failure_patterns"]),W=/_fts(_config|_data|_docsize|_idx)?$/;function F(e){const t=e.getDatabase(),s=[...b],r=new Set(b.map(o=>o.table));try{const o=t.prepare(`SELECT name FROM sqlite_master
2
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'`).all();for(const n of o){const l=n.name;if(r.has(l)||U.has(l)||W.test(l)||!h.test(l))continue;const _=t.prepare(`PRAGMA table_info(${l})`).all();if(!_.some(c=>c.name==="id"))continue;const d=_.some(c=>c.name==="cross_project_visibility");s.push({kind:l,table:l,hasVisibility:d})}}catch{}return s}function re(e){return e}function M(e,t){if(!h.test(t))return new Set;try{const r=e.getDatabase().prepare(`PRAGMA table_info(${t})`).all();return new Set(r.filter(o=>h.test(o.name)).map(o=>o.name))}catch{return new Set}}const h=/^[a-z_][a-z0-9_]*$/i;function H(){const e={pull_cursor:0,last_push_updated_ms:0,last_push_at:0,last_pull_at:0},t=T();if(!D(t))return e;try{const s=JSON.parse(O(t,"utf-8"));return{...e,...s}}catch{return e}}function V(e){k(T(),JSON.stringify(e,null,2),{mode:384})}function N(e){e.getDatabase().exec(`
174
3
  CREATE TABLE IF NOT EXISTS sync_tombstones (
175
4
  id INTEGER PRIMARY KEY AUTOINCREMENT,
176
5
  kind TEXT NOT NULL,
@@ -181,376 +10,11 @@ function ensureTombstoneTable(db) {
181
10
  );
182
11
  CREATE INDEX IF NOT EXISTS idx_sync_tombstones_pending
183
12
  ON sync_tombstones(pushed_at_ms);
184
- `);
185
- }
186
- /**
187
- * Record a deletion intent. Call this from any code path that DELETEs a
188
- * row in one of the synced tables, BEFORE the actual delete runs (so
189
- * we capture intent even if the local delete races a sync). Idempotent.
190
- *
191
- * Accepts any kind — we no longer hard-validate against the registry
192
- * because the registry is discovered dynamically at sync time.
193
- */
194
- export function recordTombstone(db, kind, rowId) {
195
- if (typeof kind !== 'string' || kind.length === 0 || kind.length > 64)
196
- return;
197
- ensureTombstoneTable(db);
198
- const raw = db.getDatabase();
199
- raw.prepare(`INSERT OR IGNORE INTO sync_tombstones (kind, row_id, deleted_at_ms)
200
- VALUES (?, ?, ?)`).run(kind, String(rowId), Date.now());
201
- }
202
- /**
203
- * Thrown when the session's machine fingerprint does not match this
204
- * machine (a copied cloud.json → device_id collision → silent 0-row
205
- * pulls, failure #40). Caught by the CLI and rendered as guidance.
206
- */
207
- export class CopiedSessionError extends Error {
208
- constructor(message) {
209
- super(message);
210
- this.name = 'CopiedSessionError';
211
- }
212
- }
213
- /** True when the operator has explicitly opted to proceed past the guard. */
214
- function copiedSessionAllowed(force) {
215
- return !!force || process.env.WYRM_ALLOW_COPIED_SESSION === '1';
216
- }
217
- /**
218
- * Guard against a copied cloud session (failure #40). Recompute this
219
- * machine's fingerprint and compare to the one stored at login:
220
- * - 'adopt' → no fp on the session (pre-7.0.3). Write the current fp
221
- * in place, silently. Avoids false alarms on every
222
- * existing install after upgrade.
223
- * - 'match' → silent, normal.
224
- * - 'mismatch' → this session was minted on another machine. Both boxes
225
- * share one device_id; cross-device pull silently returns
226
- * 0. Warn LOUD on stderr and STOP (throw), unless --force
227
- * / WYRM_ALLOW_COPIED_SESSION=1.
228
- *
229
- * stderr ONLY — stdout is the MCP wire when this module is server-imported.
230
- */
231
- export function guardCopiedSession(session, saveAdopted, force) {
232
- const verdict = classifySession(session.machine_fp);
233
- if (verdict.state === 'match')
234
- return;
235
- if (verdict.state === 'adopt') {
236
- // Backward compat: pre-7.0.3 session has no fp. Adopt this machine
237
- // silently — only a PRESENT-but-different fp is suspicious.
238
- saveAdopted(computeMachineFp());
239
- return;
240
- }
241
- // mismatch
242
- const lines = [
243
- '',
244
- '⚠ WYRM CLOUD: this session looks COPIED from another machine.',
245
- '',
246
- ` Stored machine fingerprint: ${verdict.stored.slice(0, 12)}…`,
247
- ` This machine\'s fingerprint: ${verdict.current.slice(0, 12)}…`,
248
- '',
249
- ' Both machines now share ONE device_id. The cloud pull query skips a',
250
- ' device\'s own deltas, so each machine filters the OTHER\'s changes out:',
251
- ' push works but pull silently returns 0 rows (peer memory looks MISSING).',
252
- '',
253
- ' FIX — give this machine its own identity (keep your encryption key):',
254
- ' rm ~/.wyrm/cloud.json ~/.wyrm/cloud-cursor.json # NOT cloud.key',
255
- ' wyrm cloud login',
256
- '',
257
- ' If this is a legitimate change (e.g. you renamed this host), re-run with:',
258
- ' wyrm cloud sync --force (or set WYRM_ALLOW_COPIED_SESSION=1)',
259
- '',
260
- ];
261
- if (copiedSessionAllowed(force)) {
262
- // Warn but continue. Also adopt the current fp so the operator isn't
263
- // nagged every sync after an intentional, acknowledged change.
264
- console.error(lines.join('\n'));
265
- console.error(' Proceeding anyway (--force / WYRM_ALLOW_COPIED_SESSION=1); fingerprint updated.');
266
- console.error('');
267
- saveAdopted(verdict.current);
268
- return;
269
- }
270
- console.error(lines.join('\n'));
271
- throw new CopiedSessionError('Cloud session looks copied from another machine (device_id collision). Re-login or pass --force.');
272
- }
273
- export async function runSync(opts = {}) {
274
- const session = loadSession();
275
- if (!session || !session.device_id) {
276
- throw new Error('Not logged in or device not registered. Run `wyrm cloud login`.');
277
- }
278
- // Failure #40 guard: detect a copied session before doing any work.
279
- guardCopiedSession(session, (fp) => { saveSession({ ...session, machine_fp: fp }); }, opts.force);
280
- const client = new CloudClient(session.base, session.session);
281
- const cursor = loadCursor();
282
- const db = new WyrmDB();
283
- ensureTombstoneTable(db);
284
- const result = { pushed: 0, pulled: 0, deleted_local: 0, errors: [] };
285
- // Build the table registry: defaults always; in --all mode, expand to
286
- // every discoverable syncable table in the local DB.
287
- const tables = opts.all ? discoverTables(db) : DEFAULT_TABLES;
288
- // ── PUSH ──
289
- const upserts = collectChangedRows(db, tables, cursor.last_push_updated_ms, !!opts.all, result.errors);
290
- const tombstones = collectPendingTombstones(db);
291
- const allDeltas = [
292
- ...upserts.map((r) => ({
293
- kind: r.kind,
294
- row_id: String(r.row_id),
295
- visibility: r.visibility,
296
- payload_b64: encrypt(JSON.stringify(r.row)),
297
- updated_at: r.updated_at,
298
- })),
299
- ...tombstones.map((t) => ({
300
- kind: 'tombstone',
301
- row_id: `${t.kind}:${t.row_id}`,
302
- visibility: 'org',
303
- payload_b64: encrypt(JSON.stringify({ kind: t.kind, row_id: t.row_id, deleted_at_ms: t.deleted_at_ms })),
304
- updated_at: t.deleted_at_ms,
305
- })),
306
- ];
307
- if (allDeltas.length > 0 && !opts.dryRun) {
308
- const CHUNK = 50;
309
- let maxUpdatedAt = cursor.last_push_updated_ms;
310
- for (let i = 0; i < allDeltas.length; i += CHUNK) {
311
- const chunk = allDeltas.slice(i, i + CHUNK);
312
- try {
313
- const resp = await client.syncPush(session.device_id, chunk);
314
- result.pushed += resp.accepted;
315
- if (resp.rejected > 0) {
316
- for (const r of resp.rejected_details) {
317
- result.errors.push(`push: ${r.reason} on ${r.id.slice(0, 8)}…`);
318
- }
319
- }
320
- // Track the high-water mark from what we sent in this chunk.
321
- // Server-side validates/clamps; we trust our local timestamps here.
322
- for (const d of chunk) {
323
- if (d.updated_at && d.updated_at > maxUpdatedAt)
324
- maxUpdatedAt = d.updated_at;
325
- }
326
- }
327
- catch (err) {
328
- result.errors.push(`push chunk failed: ${err instanceof Error ? err.message : err}`);
329
- }
330
- }
331
- cursor.last_push_updated_ms = maxUpdatedAt;
332
- cursor.last_push_at = Date.now();
333
- if (tombstones.length > 0)
334
- markTombstonesPushed(db, tombstones.map((t) => t.id));
335
- }
336
- else {
337
- result.pushed = allDeltas.length;
338
- }
339
- // ── PULL ──
340
- if (!opts.dryRun) {
341
- let cur = cursor.pull_cursor;
342
- let pages = 0;
343
- pullLoop: while (pages < 50) {
344
- let resp;
345
- try {
346
- resp = await client.syncPull(session.device_id, cur, 100);
347
- }
348
- catch (err) {
349
- result.errors.push(`pull failed: ${err instanceof Error ? err.message : err}`);
350
- break;
351
- }
352
- if (resp.deltas.length === 0)
353
- break;
354
- for (const d of resp.deltas) {
355
- try {
356
- const plaintext = decrypt(d.payload_b64);
357
- if (d.kind === 'tombstone') {
358
- const { kind, row_id } = JSON.parse(plaintext);
359
- if (applyTombstone(db, tables, kind, row_id))
360
- result.deleted_local++;
361
- }
362
- else {
363
- const row = JSON.parse(plaintext);
364
- applyRow(db, tables, d.kind, d.row_id, row);
365
- result.pulled++;
366
- }
367
- }
368
- catch (err) {
369
- // A delta we couldn't decrypt/parse/apply is sticky — STOP
370
- // advancing the cursor so we don't drop it on the floor.
371
- // Operator sees the error and can decide what to do (it might
372
- // be a key mismatch, a future schema, or genuine corruption).
373
- result.errors.push(`apply ${d.kind}:${d.row_id}: ${err instanceof Error ? err.message : err}`);
374
- break pullLoop;
375
- }
376
- }
377
- cur = resp.next_cursor;
378
- if (!resp.has_more)
379
- break;
380
- pages++;
381
- }
382
- cursor.pull_cursor = cur;
383
- cursor.last_pull_at = Date.now();
384
- }
385
- saveCursor(cursor);
386
- return result;
387
- }
388
- /**
389
- * Parse a SQLite `datetime('now')`-style string into ms epoch.
390
- * Format: "YYYY-MM-DD HH:MM:SS" in UTC. Falls back to 0 on parse failure.
391
- */
392
- function parseSqliteDatetimeMs(s) {
393
- if (typeof s !== 'string' || s.length === 0)
394
- return 0;
395
- // Treat as UTC. Replace space with T so Date parses ISO-like.
396
- const iso = s.includes('T') ? s : s.replace(' ', 'T') + 'Z';
397
- const t = Date.parse(iso);
398
- return Number.isFinite(t) ? t : 0;
399
- }
400
- function tableHasColumn(db, table, column) {
401
- try {
402
- const raw = db.getDatabase();
403
- // PRAGMA can't be parameterised; identifiers are validated upstream.
404
- const rows = raw.prepare(`PRAGMA table_info(${table})`).all();
405
- return rows.some((r) => r.name === column);
406
- }
407
- catch {
408
- return false;
409
- }
410
- }
411
- export function collectChangedRows(db, tables, sinceMs, allMode, errors) {
412
- const out = [];
413
- const rawDb = db.getDatabase();
414
- // [grove isolation] Gate cloud replication by projects.sync_policy when the
415
- // column is present. Computed once; cheap.
416
- const projectsHasSyncPolicy = tableHasColumn(db, 'projects', 'sync_policy');
417
- for (const t of tables) {
418
- if (!SAFE_IDENT.test(t.table))
419
- continue;
420
- // In default mode: skip tables that have no visibility column
421
- // (they were never meant for opt-in privacy). In --all mode: sync
422
- // every discovered table regardless.
423
- if (!allMode && !t.hasVisibility)
424
- continue;
425
- try {
426
- const hasUpdatedAt = tableHasColumn(db, t.table, 'updated_at');
427
- // [sec 6.3.1] Honor per-row privacy in BOTH default and --all mode:
428
- // visibility-bearing tables NEVER replicate 'within' (operator-private)
429
- // rows to the cloud. --all still widens to column-less tables, but it no
430
- // longer overrides an explicit per-row privacy marking. Use the encrypted
431
- // full-DB snapshot (cloud-backup) for a literal everything-backup.
432
- const visClause = t.hasVisibility
433
- ? `WHERE cross_project_visibility IN ('org', 'public')`
434
- : `WHERE 1=1`;
435
- // [grove isolation] Only replicate rows whose grove opts into cloud sync
436
- // (projects.sync_policy IN 'cloud','team'). Project-less rows fall back to
437
- // the per-row visibility gate above. A 'private' grove (the default, incl.
438
- // GHOST PROTOCOL) NEVER replicates, even in --all mode.
439
- const groveClause = (projectsHasSyncPolicy && tableHasColumn(db, t.table, 'project_id'))
440
- ? `AND (project_id IS NULL OR project_id IN (SELECT id FROM projects WHERE sync_policy IN ('cloud', 'team')))`
441
- : ``;
442
- const updatedClause = hasUpdatedAt
443
- ? `AND (? = 0 OR strftime('%s', updated_at) * 1000 > ?)`
444
- : ``;
445
- // ORDER BY updated_at so the chunked push processes rows in
446
- // monotonically-increasing time order. Without this, a chunk
447
- // that fails would advance the cursor past rows in EARLIER
448
- // chunks that succeeded, permanently skipping the failed rows.
449
- const orderClause = hasUpdatedAt ? `ORDER BY updated_at ASC` : ``;
450
- const sql = `SELECT * FROM ${t.table} ${visClause} ${groveClause} ${updatedClause} ${orderClause}`;
451
- const stmt = rawDb.prepare(sql);
452
- const rows = hasUpdatedAt
453
- ? stmt.all(sinceMs, sinceMs)
454
- : stmt.all();
455
- for (const row of rows) {
456
- const updatedMs = parseSqliteDatetimeMs(row.updated_at);
457
- if (sinceMs > 0 && updatedMs > 0 && updatedMs <= sinceMs)
458
- continue;
459
- // For tables without visibility, mark visibility 'org' so the
460
- // server's pull-from-peers logic can still surface them across
461
- // the operator's own devices.
462
- const vis = t.hasVisibility
463
- ? String(row.cross_project_visibility ?? 'within')
464
- : 'org';
465
- out.push({
466
- kind: t.kind,
467
- row_id: (row.id ?? '?'),
468
- visibility: vis,
469
- row,
470
- updated_at: updatedMs || Date.now(),
471
- });
472
- }
473
- }
474
- catch (err) {
475
- const msg = err instanceof Error ? err.message : String(err);
476
- if (/no such table/i.test(msg))
477
- continue;
478
- errors.push(`collect ${t.table}: ${msg}`);
479
- }
480
- }
481
- // Final pass: order by updated_at ACROSS tables too. The cursor
482
- // advances on per-chunk-success; if rows from table A and B interleave
483
- // by updated_at, we need them globally ordered so a failed B-chunk
484
- // doesn't strand A-rows on the next iteration.
485
- out.sort((a, b) => (a.updated_at ?? 0) - (b.updated_at ?? 0));
486
- return out;
487
- }
488
- function collectPendingTombstones(db) {
489
- const raw = db.getDatabase();
490
- try {
491
- return raw.prepare(`SELECT id, kind, row_id, deleted_at_ms
13
+ `)}function ie(e,t,s){if(typeof t!="string"||t.length===0||t.length>64)return;N(e),e.getDatabase().prepare(`INSERT OR IGNORE INTO sync_tombstones (kind, row_id, deleted_at_ms)
14
+ VALUES (?, ?, ?)`).run(t,String(s),Date.now())}class q extends Error{constructor(t){super(t),this.name="CopiedSessionError"}}function Y(e){return!!e||process.env.WYRM_ALLOW_COPIED_SESSION==="1"}function G(e,t,s){const r=x(e.machine_fp);if(r.state==="match")return;if(r.state==="adopt"){t(P());return}const o=["","\u26A0 WYRM CLOUD: this session looks COPIED from another machine.","",` Stored machine fingerprint: ${r.stored.slice(0,12)}\u2026`,` This machine's fingerprint: ${r.current.slice(0,12)}\u2026`,""," Both machines now share ONE device_id. The cloud pull query skips a"," device's own deltas, so each machine filters the OTHER's changes out:"," push works but pull silently returns 0 rows (peer memory looks MISSING).",""," FIX \u2014 give this machine its own identity (keep your encryption key):"," rm ~/.wyrm/cloud.json ~/.wyrm/cloud-cursor.json # NOT cloud.key"," wyrm cloud login",""," If this is a legitimate change (e.g. you renamed this host), re-run with:"," wyrm cloud sync --force (or set WYRM_ALLOW_COPIED_SESSION=1)",""];if(Y(s)){console.error(o.join(`
15
+ `)),console.error(" Proceeding anyway (--force / WYRM_ALLOW_COPIED_SESSION=1); fingerprint updated."),console.error(""),t(r.current);return}throw console.error(o.join(`
16
+ `)),new q("Cloud session looks copied from another machine (device_id collision). Re-login or pass --force.")}async function ae(e={}){const t=L();if(!t||!t.device_id)throw new Error("Not logged in or device not registered. Run `wyrm cloud login`.");G(t,i=>{A({...t,machine_fp:i})},e.force);const s=new C(t.base,t.session),r=H(),o=new $;N(o);const n={pushed:0,pulled:0,deleted_local:0,errors:[]},l=e.all?F(o):b,_=J(o,l,r.last_push_updated_ms,!!e.all,n.errors),d=K(o),c=[..._.map(i=>({kind:i.kind,row_id:String(i.row_id),visibility:i.visibility,payload_b64:S(JSON.stringify(i.row)),updated_at:i.updated_at})),...d.map(i=>({kind:"tombstone",row_id:`${i.kind}:${i.row_id}`,visibility:"org",payload_b64:S(JSON.stringify({kind:i.kind,row_id:i.row_id,deleted_at_ms:i.deleted_at_ms})),updated_at:i.deleted_at_ms}))];if(c.length>0&&!e.dryRun){let m=r.last_push_updated_ms;for(let a=0;a<c.length;a+=50){const p=c.slice(a,a+50);try{const u=await s.syncPush(t.device_id,p);if(n.pushed+=u.accepted,u.rejected>0)for(const f of u.rejected_details)n.errors.push(`push: ${f.reason} on ${f.id.slice(0,8)}\u2026`);for(const f of p)f.updated_at&&f.updated_at>m&&(m=f.updated_at)}catch(u){n.errors.push(`push chunk failed: ${u instanceof Error?u.message:u}`)}}r.last_push_updated_ms=m,r.last_push_at=Date.now(),d.length>0&&X(o,d.map(a=>a.id))}else n.pushed=c.length;if(!e.dryRun){let i=r.pull_cursor,m=0;e:for(;m<50;){let a;try{a=await s.syncPull(t.device_id,i,100)}catch(p){n.errors.push(`pull failed: ${p instanceof Error?p.message:p}`);break}if(a.deltas.length===0)break;for(const p of a.deltas)try{const u=j(p.payload_b64);if(p.kind==="tombstone"){const{kind:f,row_id:w}=JSON.parse(u);Q(o,l,f,w)&&n.deleted_local++}else{const f=JSON.parse(u);z(o,l,p.kind,p.row_id,f),n.pulled++}}catch(u){n.errors.push(`apply ${p.kind}:${p.row_id}: ${u instanceof Error?u.message:u}`);break e}if(i=a.next_cursor,!a.has_more)break;m++}r.pull_cursor=i,r.last_pull_at=Date.now()}return V(r),n}function B(e){if(typeof e!="string"||e.length===0)return 0;const t=e.includes("T")?e:e.replace(" ","T")+"Z",s=Date.parse(t);return Number.isFinite(s)?s:0}function g(e,t,s){try{return e.getDatabase().prepare(`PRAGMA table_info(${t})`).all().some(n=>n.name===s)}catch{return!1}}function J(e,t,s,r,o){const n=[],l=e.getDatabase(),_=g(e,"projects","sync_policy");for(const d of t)if(h.test(d.table)&&!(!r&&!d.hasVisibility))try{const c=g(e,d.table,"updated_at"),i=d.hasVisibility?"WHERE cross_project_visibility IN ('org', 'public')":"WHERE 1=1",m=_&&g(e,d.table,"project_id")?"AND (project_id IS NULL OR project_id IN (SELECT id FROM projects WHERE sync_policy IN ('cloud', 'team')))":"",a=c?"AND (? = 0 OR strftime('%s', updated_at) * 1000 > ?)":"",p=c?"ORDER BY updated_at ASC":"",u=`SELECT * FROM ${d.table} ${i} ${m} ${a} ${p}`,f=l.prepare(u),w=c?f.all(s,s):f.all();for(const y of w){const E=B(y.updated_at);if(s>0&&E>0&&E<=s)continue;const R=d.hasVisibility?String(y.cross_project_visibility??"within"):"org";n.push({kind:d.kind,row_id:y.id??"?",visibility:R,row:y,updated_at:E||Date.now()})}}catch(c){const i=c instanceof Error?c.message:String(c);if(/no such table/i.test(i))continue;o.push(`collect ${d.table}: ${i}`)}return n.sort((d,c)=>(d.updated_at??0)-(c.updated_at??0)),n}function K(e){const t=e.getDatabase();try{return t.prepare(`SELECT id, kind, row_id, deleted_at_ms
492
17
  FROM sync_tombstones
493
18
  WHERE pushed_at_ms IS NULL
494
19
  ORDER BY id ASC
495
- LIMIT 500`).all();
496
- }
497
- catch {
498
- return [];
499
- }
500
- }
501
- function markTombstonesPushed(db, ids) {
502
- if (ids.length === 0)
503
- return;
504
- const raw = db.getDatabase();
505
- const now = Date.now();
506
- const stmt = raw.prepare(`UPDATE sync_tombstones SET pushed_at_ms = ? WHERE id = ?`);
507
- const tx = raw.transaction((tIds) => {
508
- for (const id of tIds)
509
- stmt.run(now, id);
510
- });
511
- try {
512
- tx(ids);
513
- }
514
- catch { /* best-effort */ }
515
- }
516
- // ── Apply a pulled row (last-write-wins) ──────────────────────────────────
517
- function applyRow(db, tables, kind, _rowId, row) {
518
- const target = tables.find((t) => t.kind === kind);
519
- if (!target)
520
- return; // unknown kind — peer is on a newer/different schema
521
- if (!SAFE_IDENT.test(target.table)) {
522
- throw new Error(`apply ${kind}: refusing unsafe table name ${target.table}`);
523
- }
524
- // Build the column whitelist dynamically from the LOCAL schema. Local
525
- // schema is trusted; the peer's payload is not. This blocks any
526
- // crafted column name that doesn't exist locally — including SQL
527
- // injection attempts like `id) VALUES (...); DROP TABLE x;--`.
528
- const allowed = columnWhitelist(db, target.table);
529
- if (allowed.size === 0) {
530
- throw new Error(`apply ${kind}: ${target.table} has no readable schema`);
531
- }
532
- const cols = Object.keys(row).filter((c) => allowed.has(c) && SAFE_IDENT.test(c));
533
- if (cols.length === 0) {
534
- throw new Error(`apply ${kind}: no columns from peer payload matched local schema`);
535
- }
536
- const rawDb = db.getDatabase();
537
- const placeholders = cols.map(() => '?').join(',');
538
- const colList = cols.join(',');
539
- const values = cols.map((c) => row[c]);
540
- try {
541
- rawDb.prepare(`INSERT OR REPLACE INTO ${target.table} (${colList}) VALUES (${placeholders})`).run(...values);
542
- }
543
- catch (err) {
544
- throw new Error(`apply ${kind} failed: ${err instanceof Error ? err.message : err}`);
545
- }
546
- }
547
- /** Returns true if a row was actually deleted (vs. already gone). Throws on real DB errors. */
548
- function applyTombstone(db, tables, kind, rowId) {
549
- const target = tables.find((t) => t.kind === kind);
550
- if (!target || !SAFE_IDENT.test(target.table))
551
- return false;
552
- const raw = db.getDatabase();
553
- const res = raw.prepare(`DELETE FROM ${target.table} WHERE id = ?`).run(rowId);
554
- return res.changes > 0;
555
- }
556
- //# sourceMappingURL=sync-engine.js.map
20
+ LIMIT 500`).all()}catch{return[]}}function X(e,t){if(t.length===0)return;const s=e.getDatabase(),r=Date.now(),o=s.prepare("UPDATE sync_tombstones SET pushed_at_ms = ? WHERE id = ?"),n=s.transaction(l=>{for(const _ of l)o.run(r,_)});try{n(t)}catch{}}function z(e,t,s,r,o){const n=t.find(a=>a.kind===s);if(!n)return;if(!h.test(n.table))throw new Error(`apply ${s}: refusing unsafe table name ${n.table}`);const l=M(e,n.table);if(l.size===0)throw new Error(`apply ${s}: ${n.table} has no readable schema`);const _=Object.keys(o).filter(a=>l.has(a)&&h.test(a));if(_.length===0)throw new Error(`apply ${s}: no columns from peer payload matched local schema`);const d=e.getDatabase(),c=_.map(()=>"?").join(","),i=_.join(","),m=_.map(a=>o[a]);try{d.prepare(`INSERT OR REPLACE INTO ${n.table} (${i}) VALUES (${c})`).run(...m)}catch(a){throw new Error(`apply ${s} failed: ${a instanceof Error?a.message:a}`)}}function Q(e,t,s,r){const o=t.find(_=>_.kind===s);return!o||!h.test(o.table)?!1:e.getDatabase().prepare(`DELETE FROM ${o.table} WHERE id = ?`).run(r).changes>0}export{q as CopiedSessionError,b as DEFAULT_TABLES,J as collectChangedRows,G as guardCopiedSession,ie as recordTombstone,ae as runSync};