@codemem/server 0.30.0-alpha.2 → 0.30.0-alpha.3

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/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, verifySignature, writeCodememConfigFile } from "@codemem/core";
3
+ import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, deleteProjectScopeSettingsMapping, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, listProjectScopeCandidates, listProjectScopeSettingsMappings, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, verifySignature, writeCodememConfigFile } from "@codemem/core";
4
4
  import { serveStatic } from "@hono/node-server/serve-static";
5
5
  import { Hono } from "hono";
6
6
  import { createMiddleware } from "hono/factory";
7
7
  import { RateLimiter } from "limiter";
8
8
  import { homedir, networkInterfaces } from "node:os";
9
- import { and, count, desc, eq, inArray, isNotNull, max, ne } from "drizzle-orm";
9
+ import { and, count, desc, eq, inArray, max, ne } from "drizzle-orm";
10
10
  import { drizzle } from "drizzle-orm/better-sqlite3";
11
11
  import { createHash, randomUUID } from "node:crypto";
12
12
  //#region src/middleware.ts
@@ -641,14 +641,65 @@ function normalizeScope(raw) {
641
641
  const value = String(raw ?? "").trim().toLowerCase();
642
642
  if (value === "mine" || value === "theirs") return value;
643
643
  }
644
+ function buildViewerMemoryFilters(store, filters) {
645
+ return buildFilterClausesWithContext(filters, {
646
+ actorId: store.actorId,
647
+ deviceId: store.deviceId,
648
+ enforceScopeVisibility: true
649
+ });
650
+ }
651
+ function countVisibleMemoryRows(store, filters) {
652
+ const filterResult = buildViewerMemoryFilters(store, filters);
653
+ const clauses = ["memory_items.active = 1", ...filterResult.clauses];
654
+ const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
655
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`).get(...filterResult.params);
656
+ return Number(row?.total ?? 0);
657
+ }
658
+ function sessionAllowsArtifactAccess(store, sessionId) {
659
+ const visibleCount = countVisibleMemoryRows(store, { session_id: sessionId });
660
+ if (visibleCount === 0) return false;
661
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM memory_items
662
+ WHERE session_id = ? AND active = 1`).get(sessionId);
663
+ return visibleCount === Number(row?.total ?? 0);
664
+ }
665
+ function countVisiblePromptRows(store, project) {
666
+ const filterResult = buildViewerMemoryFilters(store, null);
667
+ const clauses = ["user_prompts.session_id IS NOT NULL", `EXISTS (
668
+ SELECT 1 FROM memory_items
669
+ WHERE memory_items.session_id = user_prompts.session_id
670
+ AND memory_items.active = 1
671
+ AND ${filterResult.clauses.join(" AND ")}
672
+ )`];
673
+ const params = [...filterResult.params];
674
+ if (project) {
675
+ clauses.unshift("user_prompts.project = ?");
676
+ params.unshift(project);
677
+ }
678
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM user_prompts WHERE ${clauses.join(" AND ")}`).get(...params);
679
+ return Number(row?.total ?? 0);
680
+ }
681
+ function countVisibleArtifactRows(store, project) {
682
+ const filterResult = buildViewerMemoryFilters(store, null);
683
+ const clauses = [`EXISTS (
684
+ SELECT 1 FROM memory_items
685
+ WHERE memory_items.session_id = artifacts.session_id
686
+ AND memory_items.active = 1
687
+ AND ${filterResult.clauses.join(" AND ")}
688
+ )`];
689
+ const params = [...filterResult.params];
690
+ const from = project ? "artifacts JOIN sessions ON sessions.id = artifacts.session_id" : "artifacts";
691
+ if (project) {
692
+ clauses.unshift("sessions.project = ?");
693
+ params.unshift(project);
694
+ }
695
+ const row = store.db.prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`).get(...params);
696
+ return Number(row?.total ?? 0);
697
+ }
644
698
  function queryMemoryPage(store, options) {
645
699
  const filters = {};
646
700
  if (options.project) filters.project = options.project;
647
701
  if (options.scope) filters.ownership_scope = options.scope;
648
- const filterResult = buildFilterClausesWithContext(filters, {
649
- actorId: store.actorId,
650
- deviceId: store.deviceId
651
- });
702
+ const filterResult = buildViewerMemoryFilters(store, filters);
652
703
  const where = ["memory_items.active = 1", ...filterResult.clauses].join(" AND ");
653
704
  const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
654
705
  const rows = store.db.prepare(`SELECT memory_items.* FROM ${from}
@@ -688,9 +739,18 @@ function memoryRoutes(getStore) {
688
739
  const store = getStore();
689
740
  {
690
741
  const limit = queryInt(c.req.query("limit"), 20);
691
- const items = drizzle(store.db, { schema }).select().from(schema.sessions).orderBy(desc(schema.sessions.started_at)).limit(limit).all().map((row) => ({
742
+ const filterResult = buildViewerMemoryFilters(store, null);
743
+ const clauses = [
744
+ "memory_items.session_id = sessions.id",
745
+ "memory_items.active = 1",
746
+ ...filterResult.clauses
747
+ ];
748
+ const items = store.db.prepare(`SELECT sessions.* FROM sessions
749
+ WHERE EXISTS (SELECT 1 FROM memory_items WHERE ${clauses.join(" AND ")})
750
+ ORDER BY sessions.started_at DESC
751
+ LIMIT ?`).all(...filterResult.params, limit).map((row) => ({
692
752
  ...row,
693
- metadata_json: fromJson(row.metadata_json)
753
+ metadata_json: fromJson(row.metadata_json ?? null)
694
754
  }));
695
755
  return c.json({ items });
696
756
  }
@@ -698,7 +758,16 @@ function memoryRoutes(getStore) {
698
758
  app.get("/api/projects", (c) => {
699
759
  const store = getStore();
700
760
  {
701
- const rows = drizzle(store.db, { schema }).selectDistinct({ project: schema.sessions.project }).from(schema.sessions).where(isNotNull(schema.sessions.project)).all();
761
+ const filterResult = buildViewerMemoryFilters(store, null);
762
+ const clauses = [
763
+ "memory_items.session_id = sessions.id",
764
+ "memory_items.active = 1",
765
+ "sessions.project IS NOT NULL",
766
+ ...filterResult.clauses
767
+ ];
768
+ const rows = store.db.prepare(`SELECT DISTINCT sessions.project AS project FROM sessions
769
+ JOIN memory_items ON memory_items.session_id = sessions.id
770
+ WHERE ${clauses.join(" AND ")}`).all(...filterResult.params);
702
771
  const projects = [...new Set(rows.map((r) => String(r.project ?? "").trim()).filter((p) => p && !p.toLowerCase().startsWith("fatal:")).map((p) => projectBasename(p)).filter(Boolean))].sort();
703
772
  return c.json({ projects });
704
773
  }
@@ -765,10 +834,6 @@ function memoryRoutes(getStore) {
765
834
  const store = getStore();
766
835
  {
767
836
  const project = c.req.query("project") || null;
768
- const count = (sql, ...params) => {
769
- const row = store.db.prepare(sql).get(...params);
770
- return Number(row?.total ?? 0);
771
- };
772
837
  let prompts;
773
838
  let artifacts;
774
839
  let memories;
@@ -790,18 +855,14 @@ function memoryRoutes(getStore) {
790
855
  return total;
791
856
  };
792
857
  if (project) {
793
- prompts = count("SELECT COUNT(*) AS total FROM user_prompts WHERE project = ?", project);
794
- artifacts = count(`SELECT COUNT(*) AS total FROM artifacts
795
- JOIN sessions ON sessions.id = artifacts.session_id
796
- WHERE sessions.project = ?`, project);
797
- memories = count(`SELECT COUNT(*) AS total FROM memory_items
798
- JOIN sessions ON sessions.id = memory_items.session_id
799
- WHERE sessions.project = ?`, project);
858
+ prompts = countVisiblePromptRows(store, project);
859
+ artifacts = countVisibleArtifactRows(store, project);
860
+ memories = countVisibleMemoryRows(store, { project });
800
861
  observations = countObservations(project);
801
862
  } else {
802
- prompts = count("SELECT COUNT(*) AS total FROM user_prompts");
803
- artifacts = count("SELECT COUNT(*) AS total FROM artifacts");
804
- memories = count("SELECT COUNT(*) AS total FROM memory_items");
863
+ prompts = countVisiblePromptRows(store);
864
+ artifacts = countVisibleArtifactRows(store);
865
+ memories = countVisibleMemoryRows(store);
805
866
  observations = countObservations();
806
867
  }
807
868
  const total = prompts + artifacts + memories;
@@ -878,6 +939,7 @@ function memoryRoutes(getStore) {
878
939
  if (!sessionIdStr) return c.json({ error: "session_id required" }, 400);
879
940
  const sessionId = parseStrictInteger(sessionIdStr);
880
941
  if (sessionId == null) return c.json({ error: "session_id must be int" }, 400);
942
+ if (!sessionAllowsArtifactAccess(store, sessionId)) return c.json({ error: "session not found" }, 404);
881
943
  const rows = drizzle(store.db, { schema }).select().from(schema.artifacts).where(eq(schema.artifacts.session_id, sessionId)).all();
882
944
  return c.json({ items: rows });
883
945
  }
@@ -892,6 +954,7 @@ function memoryRoutes(getStore) {
892
954
  }
893
955
  const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
894
956
  if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
957
+ if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
895
958
  const visibility = String(body.visibility ?? "").trim();
896
959
  if (visibility !== "private" && visibility !== "shared") return c.json({ error: "visibility must be private or shared" }, 400);
897
960
  try {
@@ -916,6 +979,7 @@ function memoryRoutes(getStore) {
916
979
  if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
917
980
  const project = String(body.project ?? "").trim();
918
981
  if (!project) return c.json({ error: "project must be a non-empty string" }, 400);
982
+ if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
919
983
  try {
920
984
  const result = store.moveMemoryProject(memoryId, project);
921
985
  return c.json(result);
@@ -936,6 +1000,7 @@ function memoryRoutes(getStore) {
936
1000
  }
937
1001
  const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
938
1002
  if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
1003
+ if (!store.get(memoryId)) return c.json({ error: "memory not found" }, 404);
939
1004
  const row = drizzle(store.db, { schema }).select().from(schema.memoryItems).where(eq(schema.memoryItems.id, memoryId)).get();
940
1005
  if (!row) return c.json({ error: "memory not found" }, 404);
941
1006
  if (!memoryOwnedBySelf(row, buildOwnershipContext(store))) return c.json({ error: "memory not owned by this device" }, 403);
@@ -1330,6 +1395,82 @@ function sortActiveMaintenanceJobs$1(jobs) {
1330
1395
  return aKind.localeCompare(bKind);
1331
1396
  });
1332
1397
  }
1398
+ var MEMORY_ID_METADATA_KEYS = [
1399
+ "pack_item_ids",
1400
+ "added_ids",
1401
+ "removed_ids",
1402
+ "retained_ids"
1403
+ ];
1404
+ function toUsageRow(row, metadata) {
1405
+ return {
1406
+ id: Number(row.id ?? 0),
1407
+ session_id: row.session_id == null ? null : Number(row.session_id),
1408
+ event: String(row.event ?? "pack"),
1409
+ tokens_read: Number(row.tokens_read ?? 0),
1410
+ tokens_written: Number(row.tokens_written ?? 0),
1411
+ tokens_saved: Number(row.tokens_saved ?? 0),
1412
+ created_at: String(row.created_at ?? ""),
1413
+ metadata_json: metadata
1414
+ };
1415
+ }
1416
+ function visibleMemoryIds(store, value) {
1417
+ if (!Array.isArray(value)) return [];
1418
+ return value.map((item) => typeof item === "number" ? item : Number(item)).filter((item) => Number.isInteger(item) && store.get(item) != null);
1419
+ }
1420
+ function sessionHasVisibleMemory(store, sessionId) {
1421
+ const filterResult = buildFilterClausesWithContext({ session_id: sessionId }, {
1422
+ actorId: store.actorId,
1423
+ deviceId: store.deviceId,
1424
+ enforceScopeVisibility: true
1425
+ });
1426
+ const clauses = ["memory_items.active = 1", ...filterResult.clauses];
1427
+ return store.db.prepare(`SELECT 1 AS found FROM memory_items WHERE ${clauses.join(" AND ")} LIMIT 1`).get(...filterResult.params) != null;
1428
+ }
1429
+ function usageRowVisible(store, row) {
1430
+ const packItemIds = row.metadata_json?.pack_item_ids;
1431
+ if (Array.isArray(packItemIds)) {
1432
+ if (packItemIds.length === 0) return row.session_id != null && sessionHasVisibleMemory(store, row.session_id);
1433
+ return visibleMemoryIds(store, packItemIds).length > 0;
1434
+ }
1435
+ return row.session_id != null && sessionHasVisibleMemory(store, row.session_id);
1436
+ }
1437
+ function sanitizePackUsageMetadata(store, metadata) {
1438
+ if (!metadata) return null;
1439
+ const sanitized = { ...metadata };
1440
+ for (const key of MEMORY_ID_METADATA_KEYS) if (Array.isArray(sanitized[key])) sanitized[key] = visibleMemoryIds(store, sanitized[key]);
1441
+ return sanitized;
1442
+ }
1443
+ function summarizeUsageEvents(rows) {
1444
+ const byEvent = /* @__PURE__ */ new Map();
1445
+ for (const row of rows) {
1446
+ const summary = byEvent.get(row.event) ?? {
1447
+ event: row.event,
1448
+ total_tokens_read: 0,
1449
+ total_tokens_written: 0,
1450
+ total_tokens_saved: 0,
1451
+ count: 0
1452
+ };
1453
+ summary.total_tokens_read += row.tokens_read;
1454
+ summary.total_tokens_written += row.tokens_written;
1455
+ summary.total_tokens_saved += row.tokens_saved;
1456
+ summary.count += 1;
1457
+ byEvent.set(row.event, summary);
1458
+ }
1459
+ return [...byEvent.values()].sort((a, b) => a.event.localeCompare(b.event));
1460
+ }
1461
+ function totalUsageEvents(rows) {
1462
+ return rows.reduce((acc, row) => ({
1463
+ tokens_read: Number(acc.tokens_read) + row.tokens_read,
1464
+ tokens_written: Number(acc.tokens_written) + row.tokens_written,
1465
+ tokens_saved: Number(acc.tokens_saved) + row.tokens_saved,
1466
+ count: Number(acc.count) + 1
1467
+ }), {
1468
+ tokens_read: 0,
1469
+ tokens_written: 0,
1470
+ tokens_saved: 0,
1471
+ count: 0
1472
+ });
1473
+ }
1333
1474
  /**
1334
1475
  * Create stats routes. The store factory is called per-request to get a
1335
1476
  * fresh connection (matching the Python viewer pattern).
@@ -1378,58 +1519,27 @@ function statsRoutes(getStore) {
1378
1519
  const store = getStore();
1379
1520
  {
1380
1521
  const projectFilter = c.req.query("project") || null;
1381
- const recentPacksQuery = projectFilter ? store.db.prepare(`SELECT usage_events.id, usage_events.session_id, usage_events.event,
1382
- usage_events.tokens_read, usage_events.tokens_written, usage_events.tokens_saved,
1383
- usage_events.created_at, usage_events.metadata_json
1384
- FROM usage_events
1385
- JOIN sessions ON sessions.id = usage_events.session_id
1386
- WHERE usage_events.event = 'pack' AND sessions.project = ?
1387
- ORDER BY usage_events.created_at DESC
1388
- LIMIT 10`) : store.db.prepare(`SELECT id, session_id, event, tokens_read, tokens_written, tokens_saved, created_at, metadata_json
1389
- FROM usage_events
1390
- WHERE event = 'pack'
1391
- ORDER BY created_at DESC
1392
- LIMIT 10`);
1393
- const eventsGlobal = store.db.prepare(`SELECT event,
1394
- SUM(tokens_read) AS total_tokens_read,
1395
- SUM(tokens_written) AS total_tokens_written,
1396
- SUM(tokens_saved) AS total_tokens_saved,
1397
- COUNT(*) AS count
1398
- FROM usage_events GROUP BY event ORDER BY event`).all();
1399
- const totalsGlobal = store.db.prepare(`SELECT COALESCE(SUM(tokens_read),0) AS tokens_read,
1400
- COALESCE(SUM(tokens_written),0) AS tokens_written,
1401
- COALESCE(SUM(tokens_saved),0) AS tokens_saved,
1402
- COUNT(*) AS count
1403
- FROM usage_events`).get();
1404
- let eventsFiltered = null;
1405
- let totalsFiltered = null;
1406
- if (projectFilter) {
1407
- eventsFiltered = store.db.prepare(`SELECT event,
1408
- SUM(tokens_read) AS total_tokens_read,
1409
- SUM(tokens_written) AS total_tokens_written,
1410
- SUM(tokens_saved) AS total_tokens_saved,
1411
- COUNT(*) AS count
1412
- FROM usage_events
1413
- JOIN sessions ON sessions.id = usage_events.session_id
1414
- WHERE sessions.project = ?
1415
- GROUP BY event ORDER BY event`).all(projectFilter);
1416
- totalsFiltered = store.db.prepare(`SELECT COALESCE(SUM(tokens_read),0) AS tokens_read,
1417
- COALESCE(SUM(tokens_written),0) AS tokens_written,
1418
- COALESCE(SUM(tokens_saved),0) AS tokens_saved,
1419
- COUNT(*) AS count
1420
- FROM usage_events
1421
- JOIN sessions ON sessions.id = usage_events.session_id
1422
- WHERE sessions.project = ?`).get(projectFilter);
1423
- }
1424
- const recentPacks = (projectFilter ? recentPacksQuery.all(projectFilter) : recentPacksQuery.all()).map((row) => ({
1425
- id: Number(row.id ?? 0),
1426
- session_id: row.session_id == null ? null : Number(row.session_id),
1427
- event: String(row.event ?? "pack"),
1428
- tokens_read: Number(row.tokens_read ?? 0),
1429
- tokens_written: Number(row.tokens_written ?? 0),
1430
- tokens_saved: Number(row.tokens_saved ?? 0),
1431
- created_at: String(row.created_at ?? ""),
1432
- metadata_json: parseMetadataJson(row.metadata_json)
1522
+ const loadUsageRows = (project) => {
1523
+ return (project ? store.db.prepare(`SELECT usage_events.id, usage_events.session_id, usage_events.event,
1524
+ usage_events.tokens_read, usage_events.tokens_written, usage_events.tokens_saved,
1525
+ usage_events.created_at, usage_events.metadata_json
1526
+ FROM usage_events
1527
+ JOIN sessions ON sessions.id = usage_events.session_id
1528
+ WHERE sessions.project = ?
1529
+ ORDER BY usage_events.created_at DESC`).all(project) : store.db.prepare(`SELECT id, session_id, event, tokens_read, tokens_written, tokens_saved,
1530
+ created_at, metadata_json
1531
+ FROM usage_events
1532
+ ORDER BY created_at DESC`).all()).map((row) => toUsageRow(row, parseMetadataJson(row.metadata_json))).filter((row) => usageRowVisible(store, row));
1533
+ };
1534
+ const globalRows = loadUsageRows();
1535
+ const filteredRows = projectFilter ? loadUsageRows(projectFilter) : null;
1536
+ const eventsGlobal = summarizeUsageEvents(globalRows);
1537
+ const totalsGlobal = totalUsageEvents(globalRows);
1538
+ const eventsFiltered = filteredRows ? summarizeUsageEvents(filteredRows) : null;
1539
+ const totalsFiltered = filteredRows ? totalUsageEvents(filteredRows) : null;
1540
+ const recentPacks = (filteredRows ?? globalRows).filter((row) => row.event === "pack").slice(0, 10).map((row) => ({
1541
+ ...row,
1542
+ metadata_json: sanitizePackUsageMetadata(store, row.metadata_json)
1433
1543
  }));
1434
1544
  return c.json({
1435
1545
  project: projectFilter,
@@ -1450,6 +1560,16 @@ function statsRoutes(getStore) {
1450
1560
  /**
1451
1561
  * Sync routes — status, peers, actors, attempts, pairing, mutations.
1452
1562
  */
1563
+ function safeSkippedSyncDetail(detail) {
1564
+ if (!detail) return null;
1565
+ return {
1566
+ reason: detail.reason,
1567
+ skipped_count: detail.skipped_count,
1568
+ scope_id: detail.scope_id ?? null,
1569
+ project: detail.project ?? null,
1570
+ visibility: detail.visibility ?? null
1571
+ };
1572
+ }
1453
1573
  var SYNC_STALE_AFTER_SECONDS = 600;
1454
1574
  var SYNC_PROTOCOL_VERSION = "2";
1455
1575
  var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
@@ -1559,12 +1679,37 @@ function optionalViewerString(body, key) {
1559
1679
  if (value == null) return null;
1560
1680
  return String(value).trim() || null;
1561
1681
  }
1682
+ function optionalViewerStrictString(body, key) {
1683
+ const value = body[key];
1684
+ if (value == null) return null;
1685
+ if (typeof value !== "string") throw new Error(`${key} must be string`);
1686
+ return value.trim() || null;
1687
+ }
1688
+ function optionalViewerStringList(body, key) {
1689
+ const value = body[key];
1690
+ if (value == null) return [];
1691
+ if (!Array.isArray(value)) throw new Error(`${key} must be an array of strings`);
1692
+ return [...new Set(value.map((item) => {
1693
+ if (typeof item !== "string") throw new Error(`${key} must be an array of strings`);
1694
+ return item.trim();
1695
+ }).filter(Boolean))];
1696
+ }
1562
1697
  function optionalViewerNumber(body, key) {
1563
1698
  const value = body[key];
1564
1699
  if (value == null || value === "") return null;
1565
1700
  const number = typeof value === "number" ? value : Number(value);
1566
1701
  return Number.isFinite(number) ? Math.trunc(number) : NaN;
1567
1702
  }
1703
+ function optionalViewerInteger(body, key) {
1704
+ const value = body[key];
1705
+ if (value == null || value === "") return null;
1706
+ const number = typeof value === "number" ? value : Number(value);
1707
+ return Number.isInteger(number) ? number : NaN;
1708
+ }
1709
+ function missingGuardrailConfirmations(warnings, confirmedTokens) {
1710
+ const confirmed = new Set(confirmedTokens);
1711
+ return warnings.filter((warning) => warning.requires_confirmation && (!warning.confirmation_token || !confirmed.has(warning.confirmation_token)));
1712
+ }
1568
1713
  function coordinatorAdminMutationStatus(message) {
1569
1714
  if (message.includes("scope_not_found") || message.includes("membership_not_found") || message.includes("group_not_found") || message.includes("Group not found") || message.includes("Scope not found") || message.includes("device_not_enrolled_for_scope_group") || message.includes("device must be enrolled")) return 404;
1570
1715
  if (message.includes("not active") || message.includes("scope_not_active") || message.includes("group_archived") || message.includes("Group is archived")) return 409;
@@ -1892,6 +2037,42 @@ function currentProjectScope(row, effective) {
1892
2037
  inherits_global: row.projects_include_json == null && row.projects_exclude_json == null
1893
2038
  };
1894
2039
  }
2040
+ function cleanPeerScopeText(value) {
2041
+ return String(value ?? "").trim() || null;
2042
+ }
2043
+ function peerAuthorizedScopes(store, peerDeviceId) {
2044
+ const deviceId = peerDeviceId.trim();
2045
+ if (!deviceId) return [];
2046
+ return store.db.prepare(`SELECT
2047
+ rs.scope_id,
2048
+ rs.label,
2049
+ rs.kind,
2050
+ rs.authority_type,
2051
+ rs.coordinator_id,
2052
+ rs.group_id,
2053
+ sm.role,
2054
+ sm.membership_epoch,
2055
+ sm.updated_at
2056
+ FROM scope_memberships sm
2057
+ JOIN replication_scopes rs ON rs.scope_id = sm.scope_id
2058
+ WHERE sm.device_id = ?
2059
+ AND sm.status = 'active'
2060
+ AND rs.status = 'active'
2061
+ AND sm.membership_epoch >= rs.membership_epoch
2062
+ ORDER BY CASE WHEN rs.authority_type = 'local' THEN 1 ELSE 0 END,
2063
+ rs.label COLLATE NOCASE,
2064
+ rs.scope_id`).all(deviceId).map((row) => ({
2065
+ scope_id: String(row.scope_id ?? ""),
2066
+ label: String(row.label ?? row.scope_id ?? ""),
2067
+ kind: String(row.kind ?? "user"),
2068
+ authority_type: String(row.authority_type ?? "local"),
2069
+ coordinator_id: cleanPeerScopeText(row.coordinator_id),
2070
+ group_id: cleanPeerScopeText(row.group_id),
2071
+ role: String(row.role ?? "member"),
2072
+ membership_epoch: Number(row.membership_epoch ?? 0),
2073
+ updated_at: cleanPeerScopeText(row.updated_at)
2074
+ }));
2075
+ }
1895
2076
  function ensureLocalActorRecord(store) {
1896
2077
  const d = drizzle(store.db, { schema });
1897
2078
  if (d.select({ actor_id: schema.actors.actor_id }).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get()) return;
@@ -2114,7 +2295,8 @@ function filterOpsForPeer(store, peerDeviceId, localDeviceId, ops, options = {})
2114
2295
  });
2115
2296
  return {
2116
2297
  allowed,
2117
- skipped: skipped?.skipped_count ?? 0
2298
+ skipped: skipped?.skipped_count ?? 0,
2299
+ skippedDetail: skipped ? safeSkippedSyncDetail(skipped) : null
2118
2300
  };
2119
2301
  }
2120
2302
  function claimedLocalActorScopeStatus(store, row) {
@@ -2155,9 +2337,10 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
2155
2337
  last_error: showDiag ? row.last_error : null,
2156
2338
  has_error: Boolean(row.last_error),
2157
2339
  claimed_local_actor: Boolean(row.claimed_local_actor),
2158
- claimed_local_actor_scope: showDiag ? claimedLocalActorScopeStatus(store, row) : null,
2340
+ claimed_local_actor_scope: claimedLocalActorScopeStatus(store, row),
2159
2341
  actor_id: row.actor_id ?? null,
2160
2342
  actor_display_name: row.actor_display_name ?? null,
2343
+ authorized_scopes: peerAuthorizedScopes(store, peerId),
2161
2344
  project_scope: { ...currentProjectScope(row, readPeerProjectFilters(store, String(row.peer_device_id ?? ""))) },
2162
2345
  recent_ops: {
2163
2346
  in: recentOps.in,
@@ -2366,7 +2549,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
2366
2549
  retained_floor_cursor: boundary.retained_floor_cursor,
2367
2550
  ops: filtered.allowed,
2368
2551
  next_cursor: nextCursor,
2369
- skipped: filtered.skipped
2552
+ skipped: filtered.skipped,
2553
+ skipped_detail: filtered.skippedDetail
2370
2554
  });
2371
2555
  } catch {
2372
2556
  return c.json({ error: "internal_error" }, 500);
@@ -2483,6 +2667,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
2483
2667
  return c.json({
2484
2668
  ...result,
2485
2669
  skipped,
2670
+ skipped_detail: filtered.skippedDetail,
2486
2671
  sync_capability: LOCAL_SYNC_CAPABILITY,
2487
2672
  scope_id: scopeRequest.scope_id
2488
2673
  });
@@ -2585,6 +2770,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2585
2770
  statusPayload.daemon_running = runtimeStatus.phase === "starting" || daemonRunning;
2586
2771
  statusPayload.daemon_detail = runtimeStatus.detail ?? daemonDetail;
2587
2772
  }
2773
+ const coordinatorSnapshot = await coordinatorStatusSnapshot(store, config);
2774
+ const coordinator = traceSync("coordinator", () => redactCoordinatorStatus(coordinatorSnapshot, showDiag));
2588
2775
  const peerRows = traceSync("peerRows", () => store.db.prepare(PEERS_QUERY).all());
2589
2776
  const recentOpsByPeer = traceSync("recentPeerOps", () => recentPeerOps(store));
2590
2777
  const scopeRejectionsByPeer = traceSync("recentScopeRejectionsByPeer", () => recentScopeRejectionsByPeer(store));
@@ -2631,8 +2818,6 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2631
2818
  };
2632
2819
  const legacyDevices = traceSync("legacyDevices", () => store.claimableLegacyDeviceIds());
2633
2820
  const sharingReview = traceSync("sharingReview", () => store.sharingReviewSummary(project));
2634
- const coordinatorSnapshot = await coordinatorStatusSnapshot(store, config);
2635
- const coordinator = traceSync("coordinator", () => redactCoordinatorStatus(coordinatorSnapshot, showDiag));
2636
2821
  let joinRequests = [];
2637
2822
  if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) try {
2638
2823
  joinRequests = await listCoordinatorJoinRequests(config);
@@ -2821,7 +3006,9 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2821
3006
  const result = await runSyncPass(store.db, peerId, { scanner: store.scanner });
2822
3007
  items.push({
2823
3008
  peer_device_id: peerId,
2824
- ...result
3009
+ ...result,
3010
+ skippedOut: void 0,
3011
+ skipped_out: safeSkippedSyncDetail(result.skippedOut)
2825
3012
  });
2826
3013
  }
2827
3014
  return c.json({ items });
@@ -2855,6 +3042,78 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2855
3042
  project_scope: currentProjectScope(row, readPeerProjectFilters(store, peerDeviceId))
2856
3043
  });
2857
3044
  });
3045
+ app.get("/api/sync/sharing-domains/settings", (c) => {
3046
+ const store = getStore();
3047
+ const limit = Math.max(1, queryInt(c.req.query("limit"), 250));
3048
+ return c.json({
3049
+ scopes: listSharingDomainSettingsScopes(store.db),
3050
+ mappings: listProjectScopeSettingsMappings(store.db),
3051
+ projects: listProjectScopeCandidates(store.db, { limit }),
3052
+ local_default_scope_id: LOCAL_DEFAULT_SCOPE_ID
3053
+ });
3054
+ });
3055
+ app.put("/api/sync/sharing-domains/project-mappings", async (c) => {
3056
+ const store = getStore();
3057
+ const body = await parseViewerJsonBody(c);
3058
+ if (!body) return c.json({ error: "invalid json" }, 400);
3059
+ try {
3060
+ const id = optionalViewerInteger(body, "id");
3061
+ const priority = optionalViewerInteger(body, "priority");
3062
+ if (Number.isNaN(id)) return c.json({ error: "id must be an integer" }, 400);
3063
+ if (Number.isNaN(priority)) return c.json({ error: "priority must be an integer" }, 400);
3064
+ const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity");
3065
+ const projectPattern = optionalViewerStrictString(body, "project_pattern");
3066
+ const scopeId = optionalViewerStrictString(body, "scope_id") ?? "";
3067
+ const source = optionalViewerStrictString(body, "source") ?? "user";
3068
+ const confirmedGuardrailTokens = optionalViewerStringList(body, "confirmed_guardrail_tokens");
3069
+ const mappingInput = {
3070
+ id,
3071
+ workspace_identity: workspaceIdentity,
3072
+ project_pattern: projectPattern,
3073
+ scope_id: scopeId,
3074
+ priority,
3075
+ source
3076
+ };
3077
+ const analysis = analyzeProjectScopeMappingChangeGuardrails(store.db, mappingInput);
3078
+ if (analysis.requested_workspace_identity?.startsWith("unmapped:") && scopeId !== LOCAL_DEFAULT_SCOPE_ID) return c.json({
3079
+ error: "unmapped_project_local_only",
3080
+ message: "Unmapped projects need a stable path, git remote, or workspace id before assigning a non-local Sharing domain.",
3081
+ guardrail_warnings: analysis.warnings
3082
+ }, 400);
3083
+ const missingConfirmations = missingGuardrailConfirmations(analysis.warnings, confirmedGuardrailTokens);
3084
+ if (missingConfirmations.length > 0) {
3085
+ const missingCodes = [...new Set(missingConfirmations.map((warning) => warning.code))];
3086
+ const missingTokens = [...new Set(missingConfirmations.map((warning) => warning.confirmation_token))].filter((token) => typeof token === "string" && token.length > 0);
3087
+ return c.json({
3088
+ error: "guardrail_confirmation_required",
3089
+ required_guardrails: missingCodes,
3090
+ required_guardrail_tokens: missingTokens,
3091
+ guardrail_warnings: analysis.warnings
3092
+ }, 409);
3093
+ }
3094
+ const mapping = upsertProjectScopeSettingsMapping(store.db, { ...mappingInput });
3095
+ return c.json({
3096
+ ok: true,
3097
+ mapping,
3098
+ guardrail_warnings: analysis.warnings
3099
+ });
3100
+ } catch (error) {
3101
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3102
+ }
3103
+ });
3104
+ app.delete("/api/sync/sharing-domains/project-mappings/:id", (c) => {
3105
+ const store = getStore();
3106
+ const id = Number(c.req.param("id"));
3107
+ try {
3108
+ const deleted = deleteProjectScopeSettingsMapping(store.db, id);
3109
+ return c.json({
3110
+ ok: true,
3111
+ deleted
3112
+ });
3113
+ } catch (error) {
3114
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
3115
+ }
3116
+ });
2858
3117
  app.post("/api/sync/peers/identity", async (c) => {
2859
3118
  const store = getStore();
2860
3119
  ensureLocalActorRecord(store);