@oar-sh/oar 0.9.10 → 0.9.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
package/server/relay-tools.md
CHANGED
|
@@ -24,7 +24,7 @@ Use `restart: true` in the request body when the user wants a real relay restart
|
|
|
24
24
|
|
|
25
25
|
## Preview servers
|
|
26
26
|
|
|
27
|
-
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Previews never expire
|
|
27
|
+
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
|
|
28
28
|
|
|
29
29
|
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
|
|
30
30
|
|
|
@@ -38,8 +38,10 @@ This relay itself is listed with "self": true when its owner switched agent sess
|
|
|
38
38
|
|
|
39
39
|
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
40
40
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
|
|
41
|
+
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
|
|
41
42
|
- A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
|
|
42
43
|
- Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
|
|
43
44
|
- Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
|
|
45
|
+
- `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
|
|
44
46
|
|
|
45
47
|
Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
|
package/server/relay.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
extractModelDescriptors,
|
|
33
33
|
normalizeContextLimitTokens,
|
|
34
34
|
} from '../shared/model-descriptors.mjs';
|
|
35
|
+
import { windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
35
36
|
|
|
36
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
37
38
|
|
|
@@ -379,7 +380,7 @@ async function spawnForegroundCliViaPowerShell(tcpConnectionToken, port) {
|
|
|
379
380
|
].join(' ');
|
|
380
381
|
|
|
381
382
|
const psCommand = `$env:COPILOT_CONNECTION_TOKEN='${tcpConnectionToken}'; & '${NODE_PATH}' ${nodeArgs}`;
|
|
382
|
-
const child = await waitForProcessSpawn('powershell.exe', [
|
|
383
|
+
const child = await waitForProcessSpawn(windowsSystemToolPath('powershell.exe'), [
|
|
383
384
|
'-NoExit',
|
|
384
385
|
'-Command',
|
|
385
386
|
psCommand,
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { compareGroupMembers } from '../public/app/conversation-group-order.mjs';
|
|
6
|
+
|
|
7
|
+
// Session groups (migration 0014): the set of conversations that share a
|
|
8
|
+
// `group_id`, with a row in `conversation_groups` for what the set itself
|
|
9
|
+
// needs: a name given by hand, and the head (the session that created the
|
|
10
|
+
// others with the remote_relay tool; NULL for a group made by hand or once
|
|
11
|
+
// the head is deleted).
|
|
12
|
+
//
|
|
13
|
+
// A session an agent creates on this relay joins its creator's group at
|
|
14
|
+
// bootstrap (`attachCreatedConversation`); the origin stays the record of who
|
|
15
|
+
// created it and is not read again for the grouping. Everything else is done
|
|
16
|
+
// by hand in the sidebar. Every reader tolerates a schema without the
|
|
17
|
+
// migration: it then sees no groups and writes nothing.
|
|
18
|
+
//
|
|
19
|
+
// A group can have a hand order (migration 0015): once the owner drags a row
|
|
20
|
+
// into place, every member after the head gets a `group_position` (10, 20,
|
|
21
|
+
// 30, ...) and keeps it; later arrivals join at the end. A group nobody
|
|
22
|
+
// reordered keeps NULL positions and the activity order. Without the column
|
|
23
|
+
// the hand order is off and moves only change the membership.
|
|
24
|
+
|
|
25
|
+
const POSITION_STEP = 10;
|
|
26
|
+
|
|
27
|
+
export const GROUP_NAME_MAX = 60;
|
|
28
|
+
|
|
29
|
+
function toText(value) {
|
|
30
|
+
return String(value ?? '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The name a group shows: its own, else the head's current title, else "Group". */
|
|
34
|
+
export function resolveGroupDisplayName({ name = null, headTitle = null } = {}) {
|
|
35
|
+
return toText(name) || toText(headTitle) || 'Group';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A group name as stored: trimmed, capped, NULL when empty. */
|
|
39
|
+
export function normalizeGroupName(value) {
|
|
40
|
+
const text = toText(value).replace(/\s+/g, ' ');
|
|
41
|
+
return text ? text.slice(0, GROUP_NAME_MAX) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rowToGroup(row) {
|
|
45
|
+
if (!row) return null;
|
|
46
|
+
return {
|
|
47
|
+
id: row.id,
|
|
48
|
+
name: row.name || null,
|
|
49
|
+
parentConversationId: row.parent_conversation_id || null,
|
|
50
|
+
createdAt: row.created_at,
|
|
51
|
+
updatedAt: row.updated_at,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createConversationGroupRepository(db) {
|
|
56
|
+
const hasTable = !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'conversation_groups'`).get();
|
|
57
|
+
const hasColumn = db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_id');
|
|
58
|
+
const enabled = hasTable && hasColumn;
|
|
59
|
+
const hasPosition = enabled && db.prepare(`PRAGMA table_info(conversations)`).all().some((column) => column.name === 'group_position');
|
|
60
|
+
if (!enabled) {
|
|
61
|
+
return {
|
|
62
|
+
enabled: false,
|
|
63
|
+
getGroup: () => null,
|
|
64
|
+
listGroupsByIds: () => new Map(),
|
|
65
|
+
groupOfConversation: () => null,
|
|
66
|
+
membersOf: () => [],
|
|
67
|
+
createGroup: () => null,
|
|
68
|
+
renameGroup: () => null,
|
|
69
|
+
setMembership: () => ({ ok: false, error: 'groups-unavailable' }),
|
|
70
|
+
moveConversation: () => ({ ok: false, error: 'groups-unavailable' }),
|
|
71
|
+
attachCreatedConversation: () => null,
|
|
72
|
+
dissolveGroup: () => false,
|
|
73
|
+
forgetConversation: () => null,
|
|
74
|
+
carryToCompacted: () => null,
|
|
75
|
+
deleteIfEmpty: () => false,
|
|
76
|
+
sweepEmpty: () => 0,
|
|
77
|
+
repair: () => ({ headsDetached: 0, groupsRemoved: 0 }),
|
|
78
|
+
freezeNamesForHeads: () => 0,
|
|
79
|
+
membershipsOf: () => ({}),
|
|
80
|
+
describeGroup: () => null,
|
|
81
|
+
orderOf: () => [],
|
|
82
|
+
positionsOf: () => ({}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const getGroup = db.prepare(`SELECT * FROM conversation_groups WHERE id = ?`);
|
|
87
|
+
const insertGroup = db.prepare(`INSERT INTO conversation_groups (id, name, parent_conversation_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
|
|
88
|
+
const updateName = db.prepare(`UPDATE conversation_groups SET name = ?, updated_at = ? WHERE id = ?`);
|
|
89
|
+
const updateParent = db.prepare(`UPDATE conversation_groups SET parent_conversation_id = ?, updated_at = ? WHERE id = ?`);
|
|
90
|
+
// A frozen head title keeps to the name cap, so a later rename is never
|
|
91
|
+
// refused for a length it did not choose.
|
|
92
|
+
const freezeName = db.prepare(`UPDATE conversation_groups SET name = COALESCE(name, substr(?, 1, ${GROUP_NAME_MAX})), parent_conversation_id = NULL, updated_at = ? WHERE id = ?`);
|
|
93
|
+
const freezeNameKeepHead = db.prepare(`UPDATE conversation_groups SET name = COALESCE(name, substr(?, 1, ${GROUP_NAME_MAX})), updated_at = ? WHERE id = ? AND name IS NULL`);
|
|
94
|
+
const findGroupHeadedBy = db.prepare(`SELECT id FROM conversation_groups WHERE parent_conversation_id = ? ORDER BY created_at ASC LIMIT 1`);
|
|
95
|
+
const listGroupsWithHead = db.prepare(`
|
|
96
|
+
SELECT g.id, g.parent_conversation_id, c.title, c.status
|
|
97
|
+
FROM conversation_groups g LEFT JOIN conversations c ON c.id = g.parent_conversation_id
|
|
98
|
+
WHERE g.parent_conversation_id IS NOT NULL
|
|
99
|
+
`);
|
|
100
|
+
const deleteGroup = db.prepare(`DELETE FROM conversation_groups WHERE id = ?`);
|
|
101
|
+
const positionSelect = hasPosition ? 'group_position' : 'NULL AS group_position';
|
|
102
|
+
const getConversation = db.prepare(`SELECT id, title, archived, status, group_id, ${positionSelect}, updated_at FROM conversations WHERE id = ?`);
|
|
103
|
+
// Every change of group sets the position too: NULL unless given.
|
|
104
|
+
const setGroupStatement = db.prepare(hasPosition
|
|
105
|
+
? `UPDATE conversations SET group_id = ?, group_position = ? WHERE id = ?`
|
|
106
|
+
: `UPDATE conversations SET group_id = ? WHERE id = ?`);
|
|
107
|
+
const setGroup = {
|
|
108
|
+
run: (groupId, conversationId, position = null) => (hasPosition
|
|
109
|
+
? setGroupStatement.run(groupId, position, conversationId)
|
|
110
|
+
: setGroupStatement.run(groupId, conversationId)),
|
|
111
|
+
};
|
|
112
|
+
const setPosition = hasPosition ? db.prepare(`UPDATE conversations SET group_position = ? WHERE id = ?`) : null;
|
|
113
|
+
const clearGroup = db.prepare(hasPosition
|
|
114
|
+
? `UPDATE conversations SET group_id = NULL, group_position = NULL WHERE group_id = ?`
|
|
115
|
+
: `UPDATE conversations SET group_id = NULL WHERE group_id = ?`);
|
|
116
|
+
const listMembersByTime = db.prepare(`
|
|
117
|
+
SELECT id, title, archived, group_id, ${positionSelect}, updated_at FROM conversations
|
|
118
|
+
WHERE group_id = ? AND status != 'deleted'
|
|
119
|
+
ORDER BY updated_at DESC
|
|
120
|
+
`);
|
|
121
|
+
const maxPosition = hasPosition
|
|
122
|
+
? db.prepare(`SELECT MAX(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
|
|
123
|
+
: null;
|
|
124
|
+
const countMembers = db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`);
|
|
125
|
+
const listEmptyGroups = db.prepare(`
|
|
126
|
+
SELECT g.id FROM conversation_groups g
|
|
127
|
+
WHERE NOT EXISTS (SELECT 1 FROM conversations c WHERE c.group_id = g.id AND c.status != 'deleted')
|
|
128
|
+
`);
|
|
129
|
+
|
|
130
|
+
/** The group's rows in display order: the head, then the members (see compareGroupMembers). */
|
|
131
|
+
const listMembers = {
|
|
132
|
+
all(groupId) {
|
|
133
|
+
const id = toText(groupId);
|
|
134
|
+
const headId = toText(getGroup.get(id)?.parent_conversation_id);
|
|
135
|
+
const rows = listMembersByTime.all(id);
|
|
136
|
+
const head = headId ? rows.find((row) => row.id === headId) || null : null;
|
|
137
|
+
const members = rows.filter((row) => row !== head).sort(compareGroupMembers);
|
|
138
|
+
return head ? [head, ...members] : members;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const positionValue = (row) => (row?.group_position == null ? null : Number(row.group_position));
|
|
143
|
+
|
|
144
|
+
/** Where a row joining `groupId` goes: after the last placed member, or NULL when nobody ordered the group. */
|
|
145
|
+
function joiningPosition(groupId) {
|
|
146
|
+
if (!hasPosition) return null;
|
|
147
|
+
const max = maxPosition.get(toText(groupId))?.n;
|
|
148
|
+
return max == null ? null : Number(max) + POSITION_STEP;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const liveConversation = (id) => {
|
|
152
|
+
const row = getConversation.get(toText(id));
|
|
153
|
+
return row && String(row.status || '') !== 'deleted' ? row : null;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function deleteIfEmpty(groupId) {
|
|
157
|
+
const id = toText(groupId);
|
|
158
|
+
if (!id) return false;
|
|
159
|
+
if (Number(countMembers.get(id)?.n || 0) > 0) return false;
|
|
160
|
+
return deleteGroup.run(id).changes > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The conversation leaves its group. A head that leaves makes its group
|
|
165
|
+
* headless: the name freezes to the head's title unless one was given by
|
|
166
|
+
* hand. An empty group is removed. Returns the previous group id.
|
|
167
|
+
*/
|
|
168
|
+
const leaveGroup = db.transaction((conversationId, nowIso, { anyStatus = false } = {}) => {
|
|
169
|
+
// A row already marked deleted (the delete path marks before it removes)
|
|
170
|
+
// still leaves its group the same way.
|
|
171
|
+
const row = anyStatus ? getConversation.get(toText(conversationId)) : liveConversation(conversationId);
|
|
172
|
+
const previous = toText(row?.group_id);
|
|
173
|
+
if (!row || !previous) return null;
|
|
174
|
+
const group = getGroup.get(previous);
|
|
175
|
+
if (group && toText(group.parent_conversation_id) === row.id) freezeName.run(row.title || null, nowIso, previous);
|
|
176
|
+
setGroup.run(null, row.id);
|
|
177
|
+
deleteIfEmpty(previous);
|
|
178
|
+
return previous;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const createGroup = db.transaction(({ name = null, parentConversationId = null, conversationIds = [] } = {}, nowIso = new Date().toISOString()) => {
|
|
182
|
+
const id = `grp_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
|
|
183
|
+
const head = liveConversation(parentConversationId);
|
|
184
|
+
insertGroup.run(id, normalizeGroupName(name), head ? head.id : null, nowIso, nowIso);
|
|
185
|
+
const members = new Set([...(head ? [head.id] : []), ...conversationIds.map(toText).filter(Boolean)]);
|
|
186
|
+
for (const memberId of members) {
|
|
187
|
+
if (!liveConversation(memberId)) continue;
|
|
188
|
+
leaveGroup(memberId, nowIso);
|
|
189
|
+
setGroup.run(id, memberId);
|
|
190
|
+
}
|
|
191
|
+
return rowToGroup(getGroup.get(id));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const setMembership = db.transaction((conversationId, groupId, nowIso = new Date().toISOString()) => {
|
|
195
|
+
const row = liveConversation(conversationId);
|
|
196
|
+
if (!row) return { ok: false, error: 'conversation-not-found' };
|
|
197
|
+
const target = toText(groupId);
|
|
198
|
+
if (target && !getGroup.get(target)) return { ok: false, error: 'group-not-found' };
|
|
199
|
+
if (toText(row.group_id) === target) return { ok: true, changed: false, groupId: target || null, previousGroupId: target || null };
|
|
200
|
+
const previousGroupId = leaveGroup(row.id, nowIso);
|
|
201
|
+
if (target) setGroup.run(target, row.id, joiningPosition(target));
|
|
202
|
+
return { ok: true, changed: true, groupId: target || null, previousGroupId };
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/** The ids of a group in display order, head first. */
|
|
206
|
+
function orderOf(groupId) {
|
|
207
|
+
return listMembers.all(groupId).map((member) => member.id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A drag in the sidebar: the conversation goes into `groupId` (null: out of
|
|
212
|
+
* its group) and lands before `beforeConversationId`, or at the end when
|
|
213
|
+
* that is null or not a member there. The group then has a hand order:
|
|
214
|
+
* every member after the head is numbered 10, 20, 30, ... in the order
|
|
215
|
+
* that results. The head stays first and unnumbered; moving it within its
|
|
216
|
+
* own group, or dropping a row before it, never displaces it.
|
|
217
|
+
*/
|
|
218
|
+
const moveConversation = db.transaction((conversationId, { groupId = null, beforeConversationId = null } = {}, nowIso = new Date().toISOString()) => {
|
|
219
|
+
const row = liveConversation(conversationId);
|
|
220
|
+
if (!row) return { ok: false, error: 'conversation-not-found' };
|
|
221
|
+
const target = toText(groupId);
|
|
222
|
+
const group = target ? getGroup.get(target) : null;
|
|
223
|
+
if (target && !group) return { ok: false, error: 'group-not-found' };
|
|
224
|
+
const current = toText(row.group_id);
|
|
225
|
+
if (!target) {
|
|
226
|
+
if (!current) return { ok: true, changed: false, groupId: null, previousGroupId: null, order: [] };
|
|
227
|
+
const previousGroupId = leaveGroup(row.id, nowIso);
|
|
228
|
+
return { ok: true, changed: true, groupId: null, previousGroupId, order: [] };
|
|
229
|
+
}
|
|
230
|
+
const headId = toText(group.parent_conversation_id);
|
|
231
|
+
if (current === target && row.id === headId) {
|
|
232
|
+
return { ok: true, changed: false, groupId: target, previousGroupId: target, order: orderOf(target) };
|
|
233
|
+
}
|
|
234
|
+
let changed = false;
|
|
235
|
+
let previousGroupId = current || null;
|
|
236
|
+
if (current !== target) {
|
|
237
|
+
previousGroupId = leaveGroup(row.id, nowIso);
|
|
238
|
+
setGroup.run(target, row.id);
|
|
239
|
+
changed = true;
|
|
240
|
+
}
|
|
241
|
+
// A row that turns out to be the head (a link kept for a re-import) leads unnumbered.
|
|
242
|
+
if (hasPosition && row.id !== headId) {
|
|
243
|
+
const rows = listMembers.all(target).filter((member) => member.id !== headId);
|
|
244
|
+
const before = toText(beforeConversationId);
|
|
245
|
+
const moved = rows.find((member) => member.id === row.id);
|
|
246
|
+
// Dropped before itself: it stays where it is.
|
|
247
|
+
const others = before === row.id ? rows : rows.filter((member) => member !== moved);
|
|
248
|
+
if (before !== row.id) {
|
|
249
|
+
const index = headId && before === headId ? 0 : (before ? others.findIndex((member) => member.id === before) : -1);
|
|
250
|
+
others.splice(index < 0 ? others.length : index, 0, moved);
|
|
251
|
+
}
|
|
252
|
+
others.forEach((member, index) => {
|
|
253
|
+
const position = (index + 1) * POSITION_STEP;
|
|
254
|
+
if (positionValue(member) === position) return;
|
|
255
|
+
setPosition.run(position, member.id);
|
|
256
|
+
changed = true;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, changed, groupId: target, previousGroupId, order: orderOf(target) };
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/** `{ [conversationId]: position | null }` for the ids given (rows outside a group read as null). */
|
|
263
|
+
function positionsOf(conversationIds) {
|
|
264
|
+
const out = {};
|
|
265
|
+
for (const raw of new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean))) {
|
|
266
|
+
const row = liveConversation(raw);
|
|
267
|
+
out[raw] = row && toText(row.group_id) ? positionValue(row) : null;
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Bootstrap of a session an agent created here: the child joins the
|
|
274
|
+
* creator's group, or a new group with the creator as head. A creator that
|
|
275
|
+
* is gone leaves the child ungrouped.
|
|
276
|
+
*/
|
|
277
|
+
const attachCreatedConversation = db.transaction((childId, creatorId, nowIso = new Date().toISOString()) => {
|
|
278
|
+
const child = liveConversation(childId);
|
|
279
|
+
const creator = liveConversation(creatorId);
|
|
280
|
+
if (!child || !creator || child.id === creator.id) return null;
|
|
281
|
+
let groupId = toText(creator.group_id);
|
|
282
|
+
if (!groupId || !getGroup.get(groupId)) {
|
|
283
|
+
// A group that still names this creator as its head (its row was
|
|
284
|
+
// removed and made again, as a cache rebuild does) is taken up again
|
|
285
|
+
// rather than doubled.
|
|
286
|
+
const headed = findGroupHeadedBy.get(creator.id);
|
|
287
|
+
groupId = headed ? toText(headed.id) : createGroup({ parentConversationId: creator.id }, nowIso).id;
|
|
288
|
+
setGroup.run(groupId, creator.id);
|
|
289
|
+
}
|
|
290
|
+
setGroup.run(groupId, child.id, joiningPosition(groupId));
|
|
291
|
+
return groupId;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* What a delete path that does not know groups leaves behind: a head whose
|
|
296
|
+
* row is tombstoned (the group stays headless, the title frozen as its
|
|
297
|
+
* name) and groups without a live member. A head whose row is simply
|
|
298
|
+
* absent keeps its link: a cache wipe froze its name first, and the row
|
|
299
|
+
* may be imported again under the same id and take the group up.
|
|
300
|
+
*/
|
|
301
|
+
const repair = db.transaction((nowIso = new Date().toISOString()) => {
|
|
302
|
+
let headsDetached = 0;
|
|
303
|
+
for (const row of listGroupsWithHead.all()) {
|
|
304
|
+
if (!row.status || String(row.status) !== 'deleted') continue;
|
|
305
|
+
freezeName.run(row.title || null, nowIso, row.id);
|
|
306
|
+
headsDetached += 1;
|
|
307
|
+
}
|
|
308
|
+
let groupsRemoved = 0;
|
|
309
|
+
for (const row of listEmptyGroups.all()) groupsRemoved += deleteGroup.run(row.id).changes;
|
|
310
|
+
return { headsDetached, groupsRemoved };
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Before rows are wiped (the cache rebuild): the groups headed by any of
|
|
315
|
+
* them get their head's title as their name, so the group that survives
|
|
316
|
+
* (its hand-titled members stay) is not nameless. The head link stays for
|
|
317
|
+
* a re-import. Returns how many names were frozen.
|
|
318
|
+
*/
|
|
319
|
+
const freezeNamesForHeads = db.transaction((conversationIds, nowIso = new Date().toISOString()) => {
|
|
320
|
+
const ids = new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean));
|
|
321
|
+
let frozen = 0;
|
|
322
|
+
for (const row of listGroupsWithHead.all()) {
|
|
323
|
+
if (!ids.has(toText(row.parent_conversation_id))) continue;
|
|
324
|
+
frozen += freezeNameKeepHead.run(row.title || null, nowIso, row.id).changes;
|
|
325
|
+
}
|
|
326
|
+
return frozen;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
/** `{ [conversationId]: { groupId, role } | null }` for the ids given (deleted rows read as null). */
|
|
330
|
+
function membershipsOf(conversationIds) {
|
|
331
|
+
const out = {};
|
|
332
|
+
for (const raw of new Set((Array.isArray(conversationIds) ? conversationIds : []).map(toText).filter(Boolean))) {
|
|
333
|
+
const row = liveConversation(raw);
|
|
334
|
+
const groupId = toText(row?.group_id);
|
|
335
|
+
const group = groupId ? getGroup.get(groupId) : null;
|
|
336
|
+
out[raw] = row && group
|
|
337
|
+
? { groupId, role: toText(group.parent_conversation_id) === row.id ? 'head' : 'member' }
|
|
338
|
+
: null;
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
enabled: true,
|
|
345
|
+
getGroup(groupId) {
|
|
346
|
+
return rowToGroup(getGroup.get(toText(groupId)));
|
|
347
|
+
},
|
|
348
|
+
/** `Map<id, group>` for the ids given (unknown ids are left out). */
|
|
349
|
+
listGroupsByIds(groupIds) {
|
|
350
|
+
const map = new Map();
|
|
351
|
+
for (const raw of new Set((Array.isArray(groupIds) ? groupIds : []).map(toText).filter(Boolean))) {
|
|
352
|
+
const group = rowToGroup(getGroup.get(raw));
|
|
353
|
+
if (group) map.set(group.id, group);
|
|
354
|
+
}
|
|
355
|
+
return map;
|
|
356
|
+
},
|
|
357
|
+
groupOfConversation(conversationId) {
|
|
358
|
+
const row = liveConversation(conversationId);
|
|
359
|
+
return row?.group_id ? rowToGroup(getGroup.get(row.group_id)) : null;
|
|
360
|
+
},
|
|
361
|
+
/**
|
|
362
|
+
* `[{ id, title, archived, updatedAt, groupPosition }]` in display order
|
|
363
|
+
* (the head, then the placed members, then the others newest first),
|
|
364
|
+
* deleted rows left out.
|
|
365
|
+
*/
|
|
366
|
+
membersOf(groupId) {
|
|
367
|
+
return listMembers.all(toText(groupId)).map((row) => ({
|
|
368
|
+
id: row.id,
|
|
369
|
+
title: row.title,
|
|
370
|
+
archived: Number(row.archived || 0) === 1,
|
|
371
|
+
updatedAt: row.updated_at,
|
|
372
|
+
groupPosition: positionValue(row),
|
|
373
|
+
}));
|
|
374
|
+
},
|
|
375
|
+
createGroup,
|
|
376
|
+
renameGroup(groupId, name, nowIso = new Date().toISOString()) {
|
|
377
|
+
const id = toText(groupId);
|
|
378
|
+
if (!getGroup.get(id)) return null;
|
|
379
|
+
updateName.run(normalizeGroupName(name), nowIso, id);
|
|
380
|
+
return rowToGroup(getGroup.get(id));
|
|
381
|
+
},
|
|
382
|
+
setMembership,
|
|
383
|
+
moveConversation,
|
|
384
|
+
orderOf,
|
|
385
|
+
positionsOf,
|
|
386
|
+
attachCreatedConversation,
|
|
387
|
+
/** Every member leaves; the group row goes. Conversations are untouched otherwise. */
|
|
388
|
+
dissolveGroup(groupId) {
|
|
389
|
+
const id = toText(groupId);
|
|
390
|
+
if (!getGroup.get(id)) return false;
|
|
391
|
+
clearGroup.run(id);
|
|
392
|
+
deleteGroup.run(id);
|
|
393
|
+
return true;
|
|
394
|
+
},
|
|
395
|
+
/**
|
|
396
|
+
* Called before a conversation's rows are deleted: a head leaves its group
|
|
397
|
+
* (name frozen), and a group left empty goes. Returns the group id it was in.
|
|
398
|
+
*/
|
|
399
|
+
forgetConversation(conversationId, nowIso = new Date().toISOString()) {
|
|
400
|
+
return leaveGroup(conversationId, nowIso, { anyStatus: true });
|
|
401
|
+
},
|
|
402
|
+
/** Compaction: the new conversation takes the old one's group, its place in it, and its head role. */
|
|
403
|
+
carryToCompacted(sourceId, targetId, nowIso = new Date().toISOString()) {
|
|
404
|
+
const source = liveConversation(sourceId);
|
|
405
|
+
const groupId = toText(source?.group_id);
|
|
406
|
+
if (!groupId || !getGroup.get(groupId) || !getConversation.get(toText(targetId))) return null;
|
|
407
|
+
setGroup.run(groupId, toText(targetId), positionValue(source));
|
|
408
|
+
// The old row stays in the group, unplaced: it must not tie with the new one.
|
|
409
|
+
if (toText(targetId) !== source.id) setGroup.run(groupId, source.id, null);
|
|
410
|
+
const group = getGroup.get(groupId);
|
|
411
|
+
if (toText(group?.parent_conversation_id) === source.id) updateParent.run(toText(targetId), nowIso, groupId);
|
|
412
|
+
return groupId;
|
|
413
|
+
},
|
|
414
|
+
deleteIfEmpty,
|
|
415
|
+
/** Removes groups without a live member; returns how many. */
|
|
416
|
+
sweepEmpty() {
|
|
417
|
+
let removed = 0;
|
|
418
|
+
for (const row of listEmptyGroups.all()) removed += deleteGroup.run(row.id).changes;
|
|
419
|
+
return removed;
|
|
420
|
+
},
|
|
421
|
+
repair,
|
|
422
|
+
freezeNamesForHeads,
|
|
423
|
+
membershipsOf,
|
|
424
|
+
/**
|
|
425
|
+
* The group as the list payload and the change event describe it: name,
|
|
426
|
+
* display name (the head's current title when none was given), head and
|
|
427
|
+
* member counts. `headTitle(title)` may dress the title as the list does.
|
|
428
|
+
*/
|
|
429
|
+
describeGroup(groupId, { headTitle = (title) => title } = {}) {
|
|
430
|
+
const group = rowToGroup(getGroup.get(toText(groupId)));
|
|
431
|
+
if (!group) return null;
|
|
432
|
+
const members = listMembers.all(group.id);
|
|
433
|
+
const head = group.parentConversationId ? members.find((member) => member.id === group.parentConversationId) || null : null;
|
|
434
|
+
return {
|
|
435
|
+
id: group.id,
|
|
436
|
+
name: group.name || null,
|
|
437
|
+
displayName: resolveGroupDisplayName({ name: group.name, headTitle: head ? headTitle(head.title) : null }),
|
|
438
|
+
parentConversationId: group.parentConversationId || null,
|
|
439
|
+
memberCount: members.length,
|
|
440
|
+
liveMemberCount: members.filter((member) => Number(member.archived || 0) !== 1).length,
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
|
|
4
4
|
import { createMessagePinStatements } from './message-pin-statements.mjs';
|
|
5
|
+
import { createConversationHistoryPageStatements } from '../services/conversation-history-page.mjs';
|
|
5
6
|
import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
|
|
6
7
|
|
|
7
8
|
export function createMessageRepository(db) {
|
|
@@ -27,8 +28,11 @@ export function createMessageRepository(db) {
|
|
|
27
28
|
// messages
|
|
28
29
|
getMessages: db.prepare(`SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC`),
|
|
29
30
|
getSharedMessages: shareVisibility.getSharedMessages,
|
|
31
|
+
// One history page in SQL (conversation-history-page.mjs).
|
|
32
|
+
conversationHistoryPage: createConversationHistoryPageStatements(db),
|
|
30
33
|
getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
|
|
31
34
|
setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
|
|
35
|
+
listHiddenMessages: shareVisibility.listHiddenMessages,
|
|
32
36
|
setMessagePinnedAt: messagePins.setMessagePinnedAt,
|
|
33
37
|
listPinnedMessages: messagePins.listPinnedMessages,
|
|
34
38
|
countPinnedMessages: messagePins.countPinnedMessages,
|
|
@@ -115,6 +119,11 @@ export function createMessageRepository(db) {
|
|
|
115
119
|
ORDER BY captured_at DESC
|
|
116
120
|
LIMIT 1
|
|
117
121
|
`),
|
|
122
|
+
getMessageUsageSnapshotByResponse: db.prepare(`
|
|
123
|
+
SELECT *
|
|
124
|
+
FROM message_usage_snapshots
|
|
125
|
+
WHERE response_message_id = ? AND conversation_id = ?
|
|
126
|
+
`),
|
|
118
127
|
listMessageUsageSnapshotsByConversation: db.prepare(`
|
|
119
128
|
SELECT *
|
|
120
129
|
FROM message_usage_snapshots
|
|
@@ -174,6 +174,10 @@ export function createQuestionRepository(db) {
|
|
|
174
174
|
WHERE response_message_id = ?
|
|
175
175
|
ORDER BY created_at ASC, id ASC
|
|
176
176
|
`),
|
|
177
|
+
// Every card of a conversation, for the shared view's link redaction:
|
|
178
|
+
// a paged transcript hydrates only its page's cards, yet a reply on
|
|
179
|
+
// the page may quote the link of a card outside it.
|
|
180
|
+
listPreviewCardJsonByConversation: db.prepare(`SELECT preview_json FROM preview_cards WHERE conversation_id = ?`),
|
|
177
181
|
deleteConvPreviewCards: db.prepare(`DELETE FROM preview_cards WHERE conversation_id = ?`),
|
|
178
182
|
|
|
179
183
|
// relay boards
|
|
@@ -24,6 +24,16 @@ export function createSessionRepository(db) {
|
|
|
24
24
|
? 'rs.claude_cloud_cost_usd AS runtime_claude_cloud_cost_usd'
|
|
25
25
|
: 'NULL AS runtime_claude_cloud_cost_usd',
|
|
26
26
|
].join(', ');
|
|
27
|
+
// Session groups (migration 0014). NULL on a schema that predates it.
|
|
28
|
+
const conversationsHaveGroup = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
29
|
+
.some((column) => String(column?.name || '').trim() === 'group_id');
|
|
30
|
+
// The hand order inside a group (migration 0015).
|
|
31
|
+
const conversationsHaveGroupPosition = db.prepare(`PRAGMA table_info(conversations)`).all()
|
|
32
|
+
.some((column) => String(column?.name || '').trim() === 'group_position');
|
|
33
|
+
const conversationGroupSelect = [
|
|
34
|
+
conversationsHaveGroup ? 'c.group_id' : 'NULL AS group_id',
|
|
35
|
+
conversationsHaveGroupPosition ? 'c.group_position' : 'NULL AS group_position',
|
|
36
|
+
].join(', ');
|
|
27
37
|
return {
|
|
28
38
|
// conversations
|
|
29
39
|
getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
|
|
@@ -31,7 +41,7 @@ export function createSessionRepository(db) {
|
|
|
31
41
|
getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
|
|
32
42
|
listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
|
|
33
43
|
runtimeSessionsSupportProviders,
|
|
34
|
-
listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
|
|
44
|
+
listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
|
|
35
45
|
insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
|
|
36
46
|
updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
|
|
37
47
|
updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
|
|
@@ -301,47 +311,12 @@ export function createSessionRepository(db) {
|
|
|
301
311
|
FROM conversations
|
|
302
312
|
WHERE status = 'deleted' AND sdk_session_id = ?
|
|
303
313
|
`),
|
|
304
|
-
// public conversation shares
|
|
305
|
-
getConversationShareByConversationId: db.prepare(`
|
|
306
|
-
SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
|
|
307
|
-
FROM conversation_shares
|
|
308
|
-
WHERE conversation_id = ?
|
|
309
|
-
AND (revoked_at IS NULL OR revoked_at = '')
|
|
310
|
-
ORDER BY created_at DESC
|
|
311
|
-
LIMIT 1
|
|
312
|
-
`),
|
|
313
|
-
getConversationShareByToken: db.prepare(`
|
|
314
|
-
SELECT token, conversation_id, created_at, last_accessed_at, revoked_at
|
|
315
|
-
FROM conversation_shares
|
|
316
|
-
WHERE token = ?
|
|
317
|
-
LIMIT 1
|
|
318
|
-
`),
|
|
319
|
-
insertConversationShare: db.prepare(`
|
|
320
|
-
INSERT INTO conversation_shares (
|
|
321
|
-
token,
|
|
322
|
-
conversation_id,
|
|
323
|
-
created_at,
|
|
324
|
-
last_accessed_at,
|
|
325
|
-
revoked_at
|
|
326
|
-
) VALUES (?, ?, ?, ?, NULL)
|
|
327
|
-
`),
|
|
314
|
+
// public conversation shares (the rest lives in share-repository.mjs)
|
|
328
315
|
touchConversationShare: db.prepare(`
|
|
329
316
|
UPDATE conversation_shares
|
|
330
317
|
SET last_accessed_at = ?
|
|
331
318
|
WHERE token = ?
|
|
332
319
|
AND (revoked_at IS NULL OR revoked_at = '')
|
|
333
320
|
`),
|
|
334
|
-
revokeConversationShareByToken: db.prepare(`
|
|
335
|
-
UPDATE conversation_shares
|
|
336
|
-
SET revoked_at = ?
|
|
337
|
-
WHERE token = ?
|
|
338
|
-
AND (revoked_at IS NULL OR revoked_at = '')
|
|
339
|
-
`),
|
|
340
|
-
revokeConversationSharesByConversationId: db.prepare(`
|
|
341
|
-
UPDATE conversation_shares
|
|
342
|
-
SET revoked_at = ?
|
|
343
|
-
WHERE conversation_id = ?
|
|
344
|
-
AND (revoked_at IS NULL OR revoked_at = '')
|
|
345
|
-
`),
|
|
346
321
|
};
|
|
347
322
|
}
|