@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: what an agent's update_session may do to a session group.
5
+ *
6
+ * - `conversation_groups.agent_made` is 1 for a group update_session made
7
+ * (a name the relay did not have). Only such a group goes once an agent
8
+ * takes its last member out.
9
+ * - `conversation_groups.kept_empty_at` is set when an agent took the last
10
+ * member out of a group the owner made: the group stays (empty), the boot
11
+ * repair and the sweep leave it alone, and agents can still name it. The
12
+ * mark goes as soon as a member joins again.
13
+ *
14
+ * Additive and idempotent. Safe to run on every boot. Rolling back needs no
15
+ * data change: older code ignores both columns (its boot repair removes an
16
+ * empty group, as it always did).
17
+ *
18
+ * Usage: node server/migrations/0021-conversation-group-agent-marks.mjs [path/to/copilot.db]
19
+ */
20
+
21
+ import { fileURLToPath } from 'url';
22
+ import path from 'path';
23
+ import process from 'process';
24
+ import Database from 'better-sqlite3';
25
+
26
+ function tableExists(db, name) {
27
+ return !!db
28
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
29
+ .get(name);
30
+ }
31
+
32
+ export function migrateConversationGroupAgentMarks(db) {
33
+ if (!tableExists(db, 'conversation_groups')) {
34
+ throw new Error('conversation_groups is missing (migration 0014 has not run)');
35
+ }
36
+ const present = new Set(db.prepare(`PRAGMA table_info(conversation_groups)`).all().map((column) => column.name));
37
+ let columnsAdded = 0;
38
+ if (!present.has('agent_made')) {
39
+ db.exec(`ALTER TABLE conversation_groups ADD COLUMN agent_made INTEGER`);
40
+ columnsAdded += 1;
41
+ }
42
+ if (!present.has('kept_empty_at')) {
43
+ db.exec(`ALTER TABLE conversation_groups ADD COLUMN kept_empty_at TEXT`);
44
+ columnsAdded += 1;
45
+ }
46
+ return { applied: columnsAdded > 0, columnsAdded };
47
+ }
48
+
49
+ function defaultDbPath() {
50
+ const here = path.dirname(fileURLToPath(import.meta.url));
51
+ return path.resolve(here, '..', 'data', 'copilot.db');
52
+ }
53
+
54
+ export function migrate(dbPath, options = {}) {
55
+ const db = new Database(dbPath, options);
56
+ try {
57
+ return migrateConversationGroupAgentMarks(db);
58
+ } finally {
59
+ db.close();
60
+ }
61
+ }
62
+
63
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
64
+ if (isMain) {
65
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
66
+ try {
67
+ const result = migrate(dbPath);
68
+ console.log(`Migration 0021 on ${dbPath}`);
69
+ console.log(result.applied
70
+ ? ` group agent marks: columns added ${result.columnsAdded}`
71
+ : ' skipped: nothing to change');
72
+ process.exit(0);
73
+ } catch (err) {
74
+ console.error(`Migration 0021 failed: ${err?.message || err}`);
75
+ process.exit(1);
76
+ }
77
+ }
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: relay schedules (services/relay-schedule-service.mjs).
5
+ *
6
+ * `relay_schedules`: one row per schedule an agent set with remote_relay's
7
+ * `schedule`.
8
+ *
9
+ * - `conversation_id`: the session the prompt goes to.
10
+ * - `created_by_conversation_id`: the conversation whose agent set it (the
11
+ * same one for a schedule of its own).
12
+ * - `at`: the first time asked for, or NULL; `every_minutes`: the interval
13
+ * of a repeating schedule, or NULL; `once`: 1 when it ends after firing.
14
+ * - `next_fire_at`: when it fires next. Written in the same transaction as
15
+ * the queue row a firing creates, so a restart neither loses a firing nor
16
+ * repeats it; the timers are rebuilt from it at boot.
17
+ * - `last_fired_at`, `fired_count`, `last_message_id` (the message its last
18
+ * firing queued: a repeat waits while that one is still pending),
19
+ * `skipped_count`.
20
+ * - `origin_json`: the provenance its messages carry (the turn that set it,
21
+ * with that turn's hops), so a schedule cannot reset the hop count;
22
+ * NULL when a user's own turn set it for its own conversation.
23
+ * - `held_since`: a firing into another session waits for the user (the
24
+ * agents' loop guard of the conversation that set it is used up).
25
+ * - `ended_at`, `end_reason`: unscheduled, fired (once), archived, deleted.
26
+ *
27
+ * Additive and idempotent. Safe to run on every boot.
28
+ *
29
+ * Usage: node server/migrations/0022-relay-schedules.mjs [path/to/copilot.db]
30
+ */
31
+
32
+ import { fileURLToPath } from 'url';
33
+ import path from 'path';
34
+ import process from 'process';
35
+ import Database from 'better-sqlite3';
36
+
37
+ export const RELAY_SCHEDULE_COLUMNS = Object.freeze([
38
+ 'id',
39
+ 'conversation_id',
40
+ 'created_by_conversation_id',
41
+ 'title',
42
+ 'text',
43
+ 'at',
44
+ 'every_minutes',
45
+ 'once',
46
+ 'next_fire_at',
47
+ 'last_fired_at',
48
+ 'fired_count',
49
+ 'skipped_count',
50
+ 'last_message_id',
51
+ 'created_at',
52
+ 'ended_at',
53
+ 'end_reason',
54
+ 'origin_json',
55
+ 'held_since',
56
+ ]);
57
+
58
+ // Added after the table's first form: added by ALTER where it lacks them.
59
+ const LATER_COLUMNS = Object.freeze([['origin_json', 'TEXT'], ['held_since', 'TEXT']]);
60
+
61
+ function tableExists(db, name) {
62
+ return !!db
63
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
64
+ .get(name);
65
+ }
66
+
67
+ /** What keeps the service from using the table: '' when nothing does. */
68
+ export function relaySchedulesTableProblem(db) {
69
+ if (!tableExists(db, 'relay_schedules')) return 'no relay_schedules table';
70
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_schedules)`).all().map((column) => column.name));
71
+ const missing = RELAY_SCHEDULE_COLUMNS.filter((name) => !present.has(name));
72
+ return missing.length ? `relay_schedules lacks ${missing.join(', ')}` : '';
73
+ }
74
+
75
+ export function migrateRelaySchedules(db) {
76
+ let tablesAdded = 0;
77
+ if (!tableExists(db, 'relay_schedules')) {
78
+ db.exec(`
79
+ CREATE TABLE relay_schedules (
80
+ id TEXT PRIMARY KEY,
81
+ conversation_id TEXT NOT NULL,
82
+ created_by_conversation_id TEXT NOT NULL,
83
+ title TEXT,
84
+ text TEXT NOT NULL,
85
+ at TEXT,
86
+ every_minutes INTEGER CHECK (every_minutes IS NULL OR every_minutes > 0),
87
+ once INTEGER NOT NULL DEFAULT 1 CHECK (once IN (0, 1)),
88
+ next_fire_at TEXT,
89
+ last_fired_at TEXT,
90
+ fired_count INTEGER NOT NULL DEFAULT 0,
91
+ skipped_count INTEGER NOT NULL DEFAULT 0,
92
+ last_message_id TEXT,
93
+ created_at TEXT NOT NULL,
94
+ ended_at TEXT,
95
+ end_reason TEXT,
96
+ origin_json TEXT,
97
+ held_since TEXT,
98
+ CHECK (once = 1 OR every_minutes IS NOT NULL)
99
+ )
100
+ `);
101
+ tablesAdded += 1;
102
+ }
103
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_schedules)`).all().map((column) => column.name));
104
+ let columnsAdded = 0;
105
+ for (const [name, type] of LATER_COLUMNS) {
106
+ if (present.has(name)) continue;
107
+ db.exec(`ALTER TABLE relay_schedules ADD COLUMN ${name} ${type}`);
108
+ columnsAdded += 1;
109
+ }
110
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_schedules_active ON relay_schedules(ended_at, next_fire_at)`);
111
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_schedules_conversation ON relay_schedules(conversation_id, ended_at)`);
112
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_schedules_creator ON relay_schedules(created_by_conversation_id, ended_at)`);
113
+ return { applied: tablesAdded > 0 || columnsAdded > 0, tablesAdded, columnsAdded };
114
+ }
115
+
116
+ function defaultDbPath() {
117
+ const here = path.dirname(fileURLToPath(import.meta.url));
118
+ return path.resolve(here, '..', 'data', 'copilot.db');
119
+ }
120
+
121
+ export function migrate(dbPath, options = {}) {
122
+ const db = new Database(dbPath, options);
123
+ try {
124
+ return migrateRelaySchedules(db);
125
+ } finally {
126
+ db.close();
127
+ }
128
+ }
129
+
130
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
131
+ if (isMain) {
132
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
133
+ try {
134
+ const result = migrate(dbPath);
135
+ console.log(`Migration 0022 on ${dbPath}`);
136
+ console.log(result.applied ? ` relay schedules: tables added ${result.tablesAdded}, columns added ${result.columnsAdded}` : ' skipped: nothing to change');
137
+ process.exit(0);
138
+ } catch (err) {
139
+ console.error(`Migration 0022 failed: ${err?.message || err}`);
140
+ process.exit(1);
141
+ }
142
+ }
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: questions that don't block (services/relay-ask-service.mjs).
5
+ *
6
+ * `relay_asks`: one row per question an agent asked the owner with
7
+ * remote_relay's `ask`. Not a `relay_questions` row: those belong to a turn
8
+ * (queue_id) that waits on them; an ask belongs to a session and blocks
9
+ * nothing.
10
+ *
11
+ * - `relay_id`: 'self' for an ask kept here (its session lives on this
12
+ * relay); a paired relay's registry id for this relay's copy of an ask its
13
+ * agent put on that relay (a mirror: `remote_id` is the ask's id there, and
14
+ * this relay looks for the answer until it has it).
15
+ * - `conversation_id`: the session the ask is in (on that relay for a
16
+ * mirror, `conversation_title` its title there).
17
+ * - `created_by`: the conversation here whose agent asked (NULL when a paired
18
+ * relay's agent did; `creator_json` then names that relay and its
19
+ * conversation, `{ relayId, relayName, conversationId, conversationTitle }`).
20
+ * - `question`, `options_json`, `recommendation`, `detail`, `urgency`
21
+ * ('normal' | 'now'): as the agent wrote them, checked
22
+ * (shared/relay-asks.mjs).
23
+ * - `status`: pending, answered, dismissed, withdrawn (by the agent that
24
+ * asked), cancelled (its session was archived or deleted); `answer`,
25
+ * `choice`, `answered_at`, `answered_via` (the relay whose Inbox it came
26
+ * from, NULL for this relay's own UI).
27
+ * - `notified_at`: when the asking side was told the outcome (for a mirror:
28
+ * when its creator here got question_answered).
29
+ *
30
+ * Additive and idempotent. Safe to run on every boot.
31
+ *
32
+ * Usage: node server/migrations/0023-relay-asks.mjs [path/to/copilot.db]
33
+ */
34
+
35
+ import { fileURLToPath } from 'url';
36
+ import path from 'path';
37
+ import process from 'process';
38
+ import Database from 'better-sqlite3';
39
+
40
+ export const RELAY_ASK_COLUMNS = Object.freeze([
41
+ 'id',
42
+ 'relay_id',
43
+ 'remote_id',
44
+ 'conversation_id',
45
+ 'conversation_title',
46
+ 'created_by',
47
+ 'creator_json',
48
+ 'question',
49
+ 'options_json',
50
+ 'recommendation',
51
+ 'detail',
52
+ 'urgency',
53
+ 'status',
54
+ 'answer',
55
+ 'choice',
56
+ 'answered_at',
57
+ 'answered_via',
58
+ 'notified_at',
59
+ 'created_at',
60
+ 'updated_at',
61
+ ]);
62
+
63
+ function tableExists(db, name) {
64
+ return !!db
65
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
66
+ .get(name);
67
+ }
68
+
69
+ /** What keeps the service from using the table: '' when nothing does. */
70
+ export function relayAsksTableProblem(db) {
71
+ if (!tableExists(db, 'relay_asks')) return 'no relay_asks table';
72
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_asks)`).all().map((column) => column.name));
73
+ const missing = RELAY_ASK_COLUMNS.filter((name) => !present.has(name));
74
+ return missing.length ? `relay_asks lacks ${missing.join(', ')}` : '';
75
+ }
76
+
77
+ export function migrateRelayAsks(db) {
78
+ let tablesAdded = 0;
79
+ if (!tableExists(db, 'relay_asks')) {
80
+ db.exec(`
81
+ CREATE TABLE relay_asks (
82
+ id TEXT PRIMARY KEY,
83
+ relay_id TEXT NOT NULL DEFAULT 'self',
84
+ remote_id TEXT,
85
+ conversation_id TEXT NOT NULL,
86
+ conversation_title TEXT,
87
+ created_by TEXT,
88
+ creator_json TEXT,
89
+ question TEXT NOT NULL,
90
+ options_json TEXT,
91
+ recommendation TEXT,
92
+ detail TEXT,
93
+ urgency TEXT NOT NULL DEFAULT 'normal' CHECK (urgency IN ('normal', 'now')),
94
+ status TEXT NOT NULL DEFAULT 'pending'
95
+ CHECK (status IN ('pending', 'answered', 'dismissed', 'withdrawn', 'cancelled')),
96
+ answer TEXT,
97
+ choice TEXT,
98
+ answered_at TEXT,
99
+ answered_via TEXT,
100
+ notified_at TEXT,
101
+ created_at TEXT NOT NULL,
102
+ updated_at TEXT NOT NULL,
103
+ CHECK (relay_id = 'self' OR remote_id IS NOT NULL)
104
+ )
105
+ `);
106
+ tablesAdded += 1;
107
+ }
108
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_status ON relay_asks(relay_id, status, created_at)`);
109
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_conversation ON relay_asks(conversation_id, status)`);
110
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_creator ON relay_asks(created_by, status)`);
111
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_relay_asks_remote ON relay_asks(relay_id, remote_id) WHERE remote_id IS NOT NULL`);
112
+ return { applied: tablesAdded > 0, tablesAdded };
113
+ }
114
+
115
+ function defaultDbPath() {
116
+ const here = path.dirname(fileURLToPath(import.meta.url));
117
+ return path.resolve(here, '..', 'data', 'copilot.db');
118
+ }
119
+
120
+ export function migrate(dbPath, options = {}) {
121
+ const db = new Database(dbPath, options);
122
+ try {
123
+ return migrateRelayAsks(db);
124
+ } finally {
125
+ db.close();
126
+ }
127
+ }
128
+
129
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
130
+ if (isMain) {
131
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
132
+ try {
133
+ const result = migrate(dbPath);
134
+ console.log(`Migration 0023 on ${dbPath}`);
135
+ console.log(result.applied ? ` relay asks: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
136
+ process.exit(0);
137
+ } catch (err) {
138
+ console.error(`Migration 0023 failed: ${err?.message || err}`);
139
+ process.exit(1);
140
+ }
141
+ }
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: DeepSeek usage totals (services/plan-usage-service.mjs, the
5
+ * DeepSeek card in Check Usage).
6
+ *
7
+ * `deepseek_usage_totals`: one row per DeepSeek model this relay's DeepSeek
8
+ * turns used, summed over every turn the workers reported
9
+ * (POST /api/deepseek-plan-usage). A row only ever grows.
10
+ *
11
+ * - `model`: the model's id as the CLI reported it, without Claude Code's
12
+ * `[1m]` suffix, lower case.
13
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`: input DeepSeek served from
14
+ * its cache, input it did not, and output.
15
+ * - `turn_count`: the reports that named the model.
16
+ * - `cost_usd`, `cost_cny`: the estimate from shared/deepseek-pricing.mjs at
17
+ * the price of each turn's own time (peak or off-peak); NULL while no turn
18
+ * of the model could be priced (a model the price table does not know).
19
+ * - `first_seen_at`, `last_seen_at`: the earliest and latest turn time.
20
+ *
21
+ * `deepseek_usage_checkpoints`: what a worker last reported per
22
+ * (conversation, Claude Code session, model). A worker reports the CLI's
23
+ * running totals, and a CLI resumed on a session starts from that session's
24
+ * earlier totals, so the relay books only what a report adds to the totals
25
+ * last reported for the session: a restarted worker or a resumed CLI books
26
+ * nothing twice, and a new session starts from zero. A total below the
27
+ * checkpoint is a CLI counting from zero again: that report is booked in
28
+ * full and the checkpoint starts over from it.
29
+ *
30
+ * - `conversation_id`, `native_session_id` (the CLI's session id), `model`.
31
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`: the totals last reported.
32
+ *
33
+ * Additive and idempotent. Safe to run on every boot.
34
+ *
35
+ * Usage: node server/migrations/0024-deepseek-usage-totals.mjs [path/to/copilot.db]
36
+ */
37
+
38
+ import { fileURLToPath } from 'url';
39
+ import path from 'path';
40
+ import process from 'process';
41
+ import Database from 'better-sqlite3';
42
+
43
+ export const DEEPSEEK_USAGE_TOTALS_TABLE = 'deepseek_usage_totals';
44
+ export const DEEPSEEK_USAGE_CHECKPOINTS_TABLE = 'deepseek_usage_checkpoints';
45
+
46
+ export const DEEPSEEK_USAGE_TOTALS_COLUMNS = Object.freeze([
47
+ 'model',
48
+ 'hit_tokens',
49
+ 'miss_tokens',
50
+ 'output_tokens',
51
+ 'turn_count',
52
+ 'cost_usd',
53
+ 'cost_cny',
54
+ 'first_seen_at',
55
+ 'last_seen_at',
56
+ 'updated_at',
57
+ ]);
58
+
59
+ export const DEEPSEEK_USAGE_CHECKPOINT_COLUMNS = Object.freeze([
60
+ 'conversation_id',
61
+ 'native_session_id',
62
+ 'model',
63
+ 'hit_tokens',
64
+ 'miss_tokens',
65
+ 'output_tokens',
66
+ 'updated_at',
67
+ ]);
68
+
69
+ function tableExists(db, name) {
70
+ return !!db
71
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
72
+ .get(name);
73
+ }
74
+
75
+ /** What keeps the service from using the table: '' when nothing does. */
76
+ export function deepseekUsageTotalsTableProblem(db) {
77
+ for (const [table, columns] of [
78
+ [DEEPSEEK_USAGE_TOTALS_TABLE, DEEPSEEK_USAGE_TOTALS_COLUMNS],
79
+ [DEEPSEEK_USAGE_CHECKPOINTS_TABLE, DEEPSEEK_USAGE_CHECKPOINT_COLUMNS],
80
+ ]) {
81
+ if (!tableExists(db, table)) return `no ${table} table`;
82
+ const present = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
83
+ const missing = columns.filter((name) => !present.has(name));
84
+ if (missing.length) return `${table} lacks ${missing.join(', ')}`;
85
+ }
86
+ return '';
87
+ }
88
+
89
+ export function migrateDeepSeekUsageTotals(db) {
90
+ let tablesAdded = 0;
91
+ if (!tableExists(db, DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
92
+ db.exec(`
93
+ CREATE TABLE ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} (
94
+ conversation_id TEXT NOT NULL,
95
+ native_session_id TEXT NOT NULL,
96
+ model TEXT NOT NULL,
97
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
98
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
99
+ output_tokens INTEGER NOT NULL DEFAULT 0,
100
+ updated_at TEXT NOT NULL,
101
+ PRIMARY KEY (conversation_id, native_session_id, model)
102
+ )
103
+ `);
104
+ tablesAdded += 1;
105
+ }
106
+ if (tableExists(db, DEEPSEEK_USAGE_TOTALS_TABLE)) return { applied: tablesAdded > 0, tablesAdded };
107
+ db.exec(`
108
+ CREATE TABLE ${DEEPSEEK_USAGE_TOTALS_TABLE} (
109
+ model TEXT PRIMARY KEY,
110
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
111
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
112
+ output_tokens INTEGER NOT NULL DEFAULT 0,
113
+ turn_count INTEGER NOT NULL DEFAULT 0,
114
+ cost_usd REAL,
115
+ cost_cny REAL,
116
+ first_seen_at TEXT NOT NULL,
117
+ last_seen_at TEXT NOT NULL,
118
+ updated_at TEXT NOT NULL
119
+ )
120
+ `);
121
+ tablesAdded += 1;
122
+ return { applied: true, tablesAdded };
123
+ }
124
+
125
+ function defaultDbPath() {
126
+ const here = path.dirname(fileURLToPath(import.meta.url));
127
+ return path.resolve(here, '..', 'data', 'copilot.db');
128
+ }
129
+
130
+ export function migrate(dbPath, options = {}) {
131
+ const db = new Database(dbPath, options);
132
+ try {
133
+ return migrateDeepSeekUsageTotals(db);
134
+ } finally {
135
+ db.close();
136
+ }
137
+ }
138
+
139
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
140
+ if (isMain) {
141
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
142
+ try {
143
+ const result = migrate(dbPath);
144
+ console.log(`Migration 0024 on ${dbPath}`);
145
+ console.log(result.applied ? ` deepseek usage totals: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
146
+ process.exit(0);
147
+ } catch (err) {
148
+ console.error(`Migration 0024 failed: ${err?.message || err}`);
149
+ process.exit(1);
150
+ }
151
+ }
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: DeepSeek web search and fetch counts, the daily spend, and the
5
+ * balance reference (services/plan-usage-service.mjs, the DeepSeek card in
6
+ * Check Usage). Builds on 0024.
7
+ *
8
+ * `deepseek_usage_totals` gains four columns:
9
+ * - `search_requests`, `fetch_requests`: Claude Code's web searches and
10
+ * fetches on DeepSeek's Search API, counted from the first turn.
11
+ * - `tools_cost_usd`, `tools_cost_cny`: what those cost, from
12
+ * shared/deepseek-pricing.mjs: 0 before DeepSeek started charging for
13
+ * them (2026-10-31 16:00 UTC), flat per request after. `cost_usd` and
14
+ * `cost_cny` stay the token estimate alone.
15
+ *
16
+ * `deepseek_usage_days`: the same sums per UTC day and model, booked in the
17
+ * same transaction as the totals, for the card's daily spend chart and this
18
+ * month's spend against the budget. History starts with this migration.
19
+ * - `day`: `YYYY-MM-DD` in UTC, from the turn's time.
20
+ * - the token, request, turn and cost columns as in the totals.
21
+ *
22
+ * `deepseek_balance_reference`: per currency, the balance after the latest
23
+ * top-up as the card's meter reads it (balance left of the last top-up).
24
+ * - `reference_total`: the balance after the latest rise; before the first
25
+ * rise is seen, the highest balance seen.
26
+ * - `last_total`: the balance last read, to see the next rise.
27
+ * - `reference_at`: when the reference was set.
28
+ *
29
+ * Additive and idempotent. Safe to run on every boot.
30
+ *
31
+ * Usage: node server/migrations/0025-deepseek-usage-requests-daily.mjs [path/to/copilot.db]
32
+ */
33
+
34
+ import { fileURLToPath } from 'url';
35
+ import path from 'path';
36
+ import process from 'process';
37
+ import Database from 'better-sqlite3';
38
+
39
+ import { DEEPSEEK_USAGE_TOTALS_TABLE, migrateDeepSeekUsageTotals } from './0024-deepseek-usage-totals.mjs';
40
+
41
+ export const DEEPSEEK_USAGE_DAYS_TABLE = 'deepseek_usage_days';
42
+ export const DEEPSEEK_BALANCE_REFERENCE_TABLE = 'deepseek_balance_reference';
43
+
44
+ // Added to deepseek_usage_totals, with their DDL.
45
+ export const DEEPSEEK_USAGE_REQUEST_COLUMNS = Object.freeze([
46
+ ['search_requests', 'INTEGER NOT NULL DEFAULT 0'],
47
+ ['fetch_requests', 'INTEGER NOT NULL DEFAULT 0'],
48
+ ['tools_cost_usd', 'REAL NOT NULL DEFAULT 0'],
49
+ ['tools_cost_cny', 'REAL NOT NULL DEFAULT 0'],
50
+ ]);
51
+
52
+ export const DEEPSEEK_USAGE_DAYS_COLUMNS = Object.freeze([
53
+ 'day',
54
+ 'model',
55
+ 'hit_tokens',
56
+ 'miss_tokens',
57
+ 'output_tokens',
58
+ 'search_requests',
59
+ 'fetch_requests',
60
+ 'turn_count',
61
+ 'cost_usd',
62
+ 'cost_cny',
63
+ 'tools_cost_usd',
64
+ 'tools_cost_cny',
65
+ 'updated_at',
66
+ ]);
67
+
68
+ export const DEEPSEEK_BALANCE_REFERENCE_COLUMNS = Object.freeze([
69
+ 'currency',
70
+ 'reference_total',
71
+ 'last_total',
72
+ 'reference_at',
73
+ 'updated_at',
74
+ ]);
75
+
76
+ function tableExists(db, name) {
77
+ return !!db
78
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
79
+ .get(name);
80
+ }
81
+
82
+ function columnNames(db, table) {
83
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
84
+ }
85
+
86
+ /** What keeps the service from using the columns and tables: '' when nothing does. */
87
+ export function deepseekUsageRequestsDailyProblem(db) {
88
+ if (!tableExists(db, DEEPSEEK_USAGE_TOTALS_TABLE)) return `no ${DEEPSEEK_USAGE_TOTALS_TABLE} table`;
89
+ const totals = columnNames(db, DEEPSEEK_USAGE_TOTALS_TABLE);
90
+ const missingColumns = DEEPSEEK_USAGE_REQUEST_COLUMNS.map(([name]) => name).filter((name) => !totals.has(name));
91
+ if (missingColumns.length) return `${DEEPSEEK_USAGE_TOTALS_TABLE} lacks ${missingColumns.join(', ')}`;
92
+ for (const [table, columns] of [
93
+ [DEEPSEEK_USAGE_DAYS_TABLE, DEEPSEEK_USAGE_DAYS_COLUMNS],
94
+ [DEEPSEEK_BALANCE_REFERENCE_TABLE, DEEPSEEK_BALANCE_REFERENCE_COLUMNS],
95
+ ]) {
96
+ if (!tableExists(db, table)) return `no ${table} table`;
97
+ const present = columnNames(db, table);
98
+ const missing = columns.filter((name) => !present.has(name));
99
+ if (missing.length) return `${table} lacks ${missing.join(', ')}`;
100
+ }
101
+ return '';
102
+ }
103
+
104
+ export function migrateDeepSeekUsageRequestsDaily(db) {
105
+ // The columns go on 0024's table: a database that skipped 0024 gets it first.
106
+ migrateDeepSeekUsageTotals(db);
107
+ let columnsAdded = 0;
108
+ let tablesAdded = 0;
109
+ const present = columnNames(db, DEEPSEEK_USAGE_TOTALS_TABLE);
110
+ for (const [name, ddl] of DEEPSEEK_USAGE_REQUEST_COLUMNS) {
111
+ if (present.has(name)) continue;
112
+ db.exec(`ALTER TABLE ${DEEPSEEK_USAGE_TOTALS_TABLE} ADD COLUMN ${name} ${ddl}`);
113
+ columnsAdded += 1;
114
+ }
115
+ if (!tableExists(db, DEEPSEEK_USAGE_DAYS_TABLE)) {
116
+ db.exec(`
117
+ CREATE TABLE ${DEEPSEEK_USAGE_DAYS_TABLE} (
118
+ day TEXT NOT NULL,
119
+ model TEXT NOT NULL,
120
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
121
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
122
+ output_tokens INTEGER NOT NULL DEFAULT 0,
123
+ search_requests INTEGER NOT NULL DEFAULT 0,
124
+ fetch_requests INTEGER NOT NULL DEFAULT 0,
125
+ turn_count INTEGER NOT NULL DEFAULT 0,
126
+ cost_usd REAL,
127
+ cost_cny REAL,
128
+ tools_cost_usd REAL NOT NULL DEFAULT 0,
129
+ tools_cost_cny REAL NOT NULL DEFAULT 0,
130
+ updated_at TEXT NOT NULL,
131
+ PRIMARY KEY (day, model)
132
+ )
133
+ `);
134
+ tablesAdded += 1;
135
+ }
136
+ if (!tableExists(db, DEEPSEEK_BALANCE_REFERENCE_TABLE)) {
137
+ db.exec(`
138
+ CREATE TABLE ${DEEPSEEK_BALANCE_REFERENCE_TABLE} (
139
+ currency TEXT PRIMARY KEY,
140
+ reference_total REAL NOT NULL,
141
+ last_total REAL NOT NULL,
142
+ reference_at TEXT NOT NULL,
143
+ updated_at TEXT NOT NULL
144
+ )
145
+ `);
146
+ tablesAdded += 1;
147
+ }
148
+ return { applied: columnsAdded + tablesAdded > 0, columnsAdded, tablesAdded };
149
+ }
150
+
151
+ function defaultDbPath() {
152
+ const here = path.dirname(fileURLToPath(import.meta.url));
153
+ return path.resolve(here, '..', 'data', 'copilot.db');
154
+ }
155
+
156
+ export function migrate(dbPath, options = {}) {
157
+ const db = new Database(dbPath, options);
158
+ try {
159
+ return migrateDeepSeekUsageRequestsDaily(db);
160
+ } finally {
161
+ db.close();
162
+ }
163
+ }
164
+
165
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
166
+ if (isMain) {
167
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
168
+ try {
169
+ const result = migrate(dbPath);
170
+ console.log(`Migration 0025 on ${dbPath}`);
171
+ console.log(result.applied
172
+ ? ` deepseek usage requests and daily: columns added ${result.columnsAdded}, tables added ${result.tablesAdded}`
173
+ : ' skipped: nothing to change');
174
+ process.exit(0);
175
+ } catch (err) {
176
+ console.error(`Migration 0025 failed: ${err?.message || err}`);
177
+ process.exit(1);
178
+ }
179
+ }