@xopcai/xopc 0.0.346 → 0.0.348
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/browser-ext/_locales/en/messages.json +3 -2
- package/dist/browser-ext/_locales/zh_CN/messages.json +3 -2
- package/dist/browser-ext/dist/assets/{auth-CFiOJ_rf.js → auth-beGtbLPS.js} +47 -33
- package/dist/browser-ext/dist/assets/{sidepanel-DD6GEMvc.css → sidepanel-BT0ojEzG.css} +2 -0
- package/dist/browser-ext/dist/background.js +5 -3
- package/dist/browser-ext/dist/sidepanel.html +2 -2
- package/dist/browser-ext/dist/sidepanel.js +34 -10
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/extensions/telegram/xopc.extension.json +1 -1
- package/dist/gateway/static/root/assets/{agent-avatar-dicebear-M2vuj6W3.js → agent-avatar-dicebear-B48Ub-fy.js} +1 -1
- package/dist/gateway/static/root/assets/{agent-defaults-settings-panel-B_d0WGFm.js → agent-defaults-settings-panel-DrNeejJd.js} +1 -1
- package/dist/gateway/static/root/assets/{agent-plugin-dialog-egTQDEeV.js → agent-plugin-dialog-B8D_L023.js} +1 -1
- package/dist/gateway/static/root/assets/{agents-BbwmG8Kl.js → agents-DRU5Cc7r.js} +3 -3
- package/dist/gateway/static/root/assets/{agents-admin-api-Dlw9XxYa.js → agents-admin-api-DZsBZ9R6.js} +1 -1
- package/dist/gateway/static/root/assets/{app-management-settings-panel-IxWZDAeT.js → app-management-settings-panel-D4huEKxu.js} +1 -1
- package/dist/gateway/static/root/assets/{appearance-settings-C56szwjn.js → appearance-settings-lbeb_mOb.js} +1 -1
- package/dist/gateway/static/root/assets/{apps-page-Cqoc4xoi.js → apps-page-B-UzNCWD.js} +1 -1
- package/dist/gateway/static/root/assets/{archive-plugin-Ce3TNO-R.js → archive-plugin-DvPF9mbR.js} +1 -1
- package/dist/gateway/static/root/assets/{automations-page-4so9_Bya.js → automations-page-CM6ANOfj.js} +1 -1
- package/dist/gateway/static/root/assets/{automations-workspace-CP3GH1iA.js → automations-workspace-7wnqz-B1.js} +1 -1
- package/dist/gateway/static/root/assets/{binary-plugins-l6tKp21u.js → binary-plugins-DYxZ2L01.js} +1 -1
- package/dist/gateway/static/root/assets/{block-editor-cj2RNjQs.js → block-editor-DPsS76ci.js} +1 -1
- package/dist/gateway/static/root/assets/{browser-automation-inputs-C9W2EklQ.js → browser-automation-inputs-BgK2Ma4E.js} +1 -1
- package/dist/gateway/static/root/assets/{browser-automations-page-CbTkFxIX.js → browser-automations-page-CT20Kt5U.js} +1 -1
- package/dist/gateway/static/root/assets/{browser-settings-page-KMfDs9dO.js → browser-settings-page-CB39uOH-.js} +1 -1
- package/dist/gateway/static/root/assets/{capabilities-Co9ZxHHB.js → capabilities-C2vyJC-B.js} +1 -1
- package/dist/gateway/static/root/assets/{capabilities-page-cm2Q7EfF.js → capabilities-page-Cfk_8vgn.js} +2 -2
- package/dist/gateway/static/root/assets/{capabilities-settings-panel-CIe5qsxW.js → capabilities-settings-panel-Boiv7dpU.js} +1 -1
- package/dist/gateway/static/root/assets/{capability-header-actions-D2ASOs4Z.js → capability-header-actions-BR4YfU1r.js} +1 -1
- package/dist/gateway/static/root/assets/{channels-D-DnLz_c.js → channels-BAu_NG0v.js} +1 -1
- package/dist/gateway/static/root/assets/{chat-terminal-dock-DaQrqzCz.js → chat-terminal-dock-DVL0pNfw.js} +1 -1
- package/dist/gateway/static/root/assets/computer-settings-page-B6bMn9_o.js +1 -0
- package/dist/gateway/static/root/assets/{connector-service-page-Cq7t7AJq.js → connector-service-page-DTf0AE08.js} +1 -1
- package/dist/gateway/static/root/assets/{connectors-page-BH53KX8Y.js → connectors-page-BrBTuiQu.js} +1 -1
- package/dist/gateway/static/root/assets/{date-picker-CcfRQKmj.js → date-picker-BvUz7Jmo.js} +1 -1
- package/dist/gateway/static/root/assets/{dependency-picker-b9Tif8dg.js → dependency-picker-BS9PH0_F.js} +1 -1
- package/dist/gateway/static/root/assets/{desktop-pet-BHe6Cb51.js → desktop-pet-CdD76KQc.js} +1 -1
- package/dist/gateway/static/root/assets/{desktop-pet-settings-wgA6yOo1.js → desktop-pet-settings-DV8cAkA-.js} +1 -1
- package/dist/gateway/static/root/assets/{device-pairing-wizard-fv8c6u1o.js → device-pairing-wizard-4wD8Pb-g.js} +1 -1
- package/dist/gateway/static/root/assets/{directory-picker-path-field-DM2YpM4N.js → directory-picker-path-field-6GF0kAvg.js} +1 -1
- package/dist/gateway/static/root/assets/{extension-debug-page-B-Y_Zk8L.js → extension-debug-page-DUkakgyK.js} +1 -1
- package/dist/gateway/static/root/assets/{extension-page-BC1m3MlB.js → extension-page-BP4yNpNV.js} +1 -1
- package/dist/gateway/static/root/assets/{extension-settings-page-Cu3cfeJj.js → extension-settings-page-IknmkkwV.js} +1 -1
- package/dist/gateway/static/root/assets/{gateway-config-swr-ukBQYwfH.js → gateway-config-swr-COCA8geG.js} +1 -1
- package/dist/gateway/static/root/assets/{gateway-realtime-DvNfXjan.js → gateway-realtime-Blcloycq.js} +1 -1
- package/dist/gateway/static/root/assets/gateway-realtime-store-B87bp5kF.js +1 -0
- package/dist/gateway/static/root/assets/{gateway-settings-pxmn3b8n.js → gateway-settings-bpaxXMOz.js} +1 -1
- package/dist/gateway/static/root/assets/{gateway-startup-retry-CVpOj205.js → gateway-startup-retry-CradGmBo.js} +1 -1
- package/dist/gateway/static/root/assets/{home-page-DOsvv2rU.js → home-page-ZqLG2X-z.js} +1 -1
- package/dist/gateway/static/root/assets/{image-generation-api-zxx0qG9z.js → image-generation-api-CCe_-wBX.js} +1 -1
- package/dist/gateway/static/root/assets/{imports-page-CWYDLeqJ.js → imports-page-M8CMzgwX.js} +1 -1
- package/dist/gateway/static/root/assets/index-0fDWYKsQ.js +106 -0
- package/dist/gateway/static/root/assets/index-s6fKtM4y.css +1 -0
- package/dist/gateway/static/root/assets/{keyboard-shortcuts-settings-BEkTI6mv.js → keyboard-shortcuts-settings-CTExu4KJ.js} +1 -1
- package/dist/gateway/static/root/assets/{local-app-workbench-page-aSXZL05r.js → local-app-workbench-page-DIkqWLTJ.js} +1 -1
- package/dist/gateway/static/root/assets/{local-apps-page-BJThsekO.js → local-apps-page-Cev794Mq.js} +1 -1
- package/dist/gateway/static/root/assets/{local-session-drafts-Dt_Yx7kq.js → local-session-drafts-CIcrQnQp.js} +1 -1
- package/dist/gateway/static/root/assets/{locale-store-B008NR-f.js → locale-store-K7mHrs6W.js} +1 -1
- package/dist/gateway/static/root/assets/{logs-page-WlqqTmpk.js → logs-page-Dj7n8b3d.js} +1 -1
- package/dist/gateway/static/root/assets/{management-settings-j_npowOA.js → management-settings-CfgGWl3l.js} +1 -1
- package/dist/gateway/static/root/assets/{markdown-view-BjDzimRP.js → markdown-view-BNVvybMV.js} +1 -1
- package/dist/gateway/static/root/assets/{media-plugins-DhYWGP6O.js → media-plugins-B6fa5xON.js} +1 -1
- package/dist/gateway/static/root/assets/messages-CY3XVeek.js +3 -0
- package/dist/gateway/static/root/assets/{note-detail-panel-CYcNB2Pf.js → note-detail-panel-DWd8h1MC.js} +3 -3
- package/dist/gateway/static/root/assets/notes-page-C6x2cjau.js +8 -0
- package/dist/gateway/static/root/assets/{page-context-capture-button-BfmOqRGN.js → page-context-capture-button-2DW_Izyo.js} +1 -1
- package/dist/gateway/static/root/assets/{page-tabs-aFGUXE53.js → page-tabs-Cjglz5aP.js} +1 -1
- package/dist/gateway/static/root/assets/{preview-open-alternatives-BCEnqJTC.js → preview-open-alternatives-2lyQ_MmL.js} +1 -1
- package/dist/gateway/static/root/assets/{product-open-page-DHcx6kDT.js → product-open-page-BKAv2MFW.js} +1 -1
- package/dist/gateway/static/root/assets/{project-detail-page-ChKkovsq.js → project-detail-page-Bz_1bF6c.js} +1 -1
- package/dist/gateway/static/root/assets/{projects-page-Lt3HXMRm.js → projects-page-DW7FI96H.js} +1 -1
- package/dist/gateway/static/root/assets/{registry-api-EPj5O77m.js → registry-api-DKxVZeHC.js} +1 -1
- package/dist/gateway/static/root/assets/{remote-access-hub-B94Vc-fj.js → remote-access-hub-zL2Mi5oM.js} +1 -1
- package/dist/gateway/static/root/assets/{runtime-tools-settings-panel-DNU19xEH.js → runtime-tools-settings-panel-iAbOoncf.js} +1 -1
- package/dist/gateway/static/root/assets/{scenes-page-BgzfnfOB.js → scenes-page-BN6zUrwp.js} +1 -1
- package/dist/gateway/static/root/assets/{schema-form-MKBLqh8C.js → schema-form-DI21ybo-.js} +1 -1
- package/dist/gateway/static/root/assets/{session-api-D5BlAIge.js → session-api-DoE_8MbN.js} +1 -1
- package/dist/gateway/static/root/assets/{session-manager-DcFS0yfO.js → session-manager-B6_KNduD.js} +2 -2
- package/dist/gateway/static/root/assets/{sessions-page-sYWhgEMH.js → sessions-page-CjfA7rah.js} +1 -1
- package/dist/gateway/static/root/assets/settings-advanced-gate-DqCD-vFm.js +1 -0
- package/dist/gateway/static/root/assets/{settings-form-section-C2gcfyxy.js → settings-form-section-B_r52FLh.js} +1 -1
- package/dist/gateway/static/root/assets/{settings-page-CYd6_yBb.js → settings-page-BNT9fll3.js} +1 -1
- package/dist/gateway/static/root/assets/{setup-status-panel-CUbH_YtB.js → setup-status-panel-QAG-Gj3Y.js} +1 -1
- package/dist/gateway/static/root/assets/{share-preview-page-Dxwq4fx1.js → share-preview-page-ByxaJBO7.js} +1 -1
- package/dist/gateway/static/root/assets/{shares-settings-CqwHm0Mf.js → shares-settings-B-H0tqRz.js} +1 -1
- package/dist/gateway/static/root/assets/{skill-api-C9k6G-tI.js → skill-api-D3Qzxo6w.js} +1 -1
- package/dist/gateway/static/root/assets/skill-reload-api-DtZy-0cc.js +1 -0
- package/dist/gateway/static/root/assets/skills-page-gCSDewRg.js +2 -0
- package/dist/gateway/static/root/assets/{system-settings-panel-1tXKr0cJ.js → system-settings-panel-DPasmTr-.js} +1 -1
- package/dist/gateway/static/root/assets/{task-detail-page-BBQ3sk4y.js → task-detail-page-4zD1dJY8.js} +1 -1
- package/dist/gateway/static/root/assets/{tunnel-api-COo0rSrc.js → tunnel-api-DshPpr1H.js} +1 -1
- package/dist/gateway/static/root/assets/{usage-page-DHXG-2Rr.js → usage-page-YXuop6gX.js} +1 -1
- package/dist/gateway/static/root/assets/{use-autosave-CJfX4SWk.js → use-autosave-CyQzBYru.js} +1 -1
- package/dist/gateway/static/root/assets/{user-model-page-d-Gjs-EX.js → user-model-page-BLCVHuv3.js} +1 -1
- package/dist/gateway/static/root/assets/{voice-api-key-field-D0gWz3iO.js → voice-api-key-field-DUv7pxEZ.js} +1 -1
- package/dist/gateway/static/root/assets/{voice-config-api-BnnUzr07.js → voice-config-api-C9HKTnnD.js} +1 -1
- package/dist/gateway/static/root/assets/{work-discovery-overlay-CDAZKFVO.js → work-discovery-overlay-DWbprlt4.js} +1 -1
- package/dist/gateway/static/root/assets/{workflow-run-setup-panel-Db0lk_9G.js → workflow-run-setup-panel-BofmD9MZ.js} +1 -1
- package/dist/gateway/static/root/assets/{workflows-page-Dj89Gagd.js → workflows-page-Ce28ZKPq.js} +1 -1
- package/dist/gateway/static/root/index.html +15 -15
- package/dist/package.js +1 -1
- package/dist/src/backup/archive.d.ts +33 -0
- package/dist/src/backup/archive.js +322 -0
- package/dist/src/cli/command-loaders.js +1 -0
- package/dist/src/cli/command-manifest.js +4 -0
- package/dist/src/cli/commands/backup.d.ts +1 -0
- package/dist/src/cli/commands/backup.js +84 -0
- package/dist/src/cli/index.js +3 -2
- package/dist/src/computer/errors.js +1 -0
- package/dist/src/gateway/hono/routes/ai-assist.js +1 -1
- package/dist/src/gateway/hono/routes/sessions.js +9 -0
- package/dist/src/gateway/service/sessions-api.d.ts +2 -3
- package/dist/src/gateway/service/sessions-api.js +5 -0
- package/dist/src/gateway/session-context-summary.js +113 -5
- package/dist/src/session/execution-detail.d.ts +16 -0
- package/dist/src/session/execution-detail.js +150 -0
- package/dist/src/tui/tui.js +1 -1
- package/package.json +3 -3
- package/dist/gateway/static/root/assets/computer-settings-page-C-59NTyH.js +0 -1
- package/dist/gateway/static/root/assets/gateway-realtime-store-DGQMXjfI.js +0 -1
- package/dist/gateway/static/root/assets/index-DsIQD44S.js +0 -106
- package/dist/gateway/static/root/assets/index-y6kxoLV2.css +0 -1
- package/dist/gateway/static/root/assets/messages-aVTaD9Mj.js +0 -3
- package/dist/gateway/static/root/assets/notes-page-4D1-mt6Y.js +0 -8
- package/dist/gateway/static/root/assets/settings-advanced-gate-alf3atpI.js +0 -1
- package/dist/gateway/static/root/assets/skill-reload-api-D9bB-ANm.js +0 -1
- package/dist/gateway/static/root/assets/skills-page-CFiCgbAs.js +0 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { init_node_sqlite, requireNodeSqlite } from "../infra/node-sqlite.js";
|
|
2
|
+
import { assertDatabaseOffline, init_conversation_backup } from "../storage/sqlite/migrations/conversation-backup.js";
|
|
3
|
+
import { expandWorkspacePathString, init_workspace_path } from "../config/workspace-path.js";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { constants, createReadStream, createWriteStream } from "node:fs";
|
|
7
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
8
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { Transform } from "node:stream";
|
|
11
|
+
//#region src/backup/archive.ts
|
|
12
|
+
init_node_sqlite();
|
|
13
|
+
init_conversation_backup();
|
|
14
|
+
init_workspace_path();
|
|
15
|
+
const FORMAT = "xopc-state-backup";
|
|
16
|
+
const VERSION = 1;
|
|
17
|
+
const OMIT_TOP_LEVEL = new Set([
|
|
18
|
+
"logs",
|
|
19
|
+
"cache",
|
|
20
|
+
"tools"
|
|
21
|
+
]);
|
|
22
|
+
const OMIT_FILES = new Set([
|
|
23
|
+
"pid",
|
|
24
|
+
"status.json",
|
|
25
|
+
"agent.sock",
|
|
26
|
+
"update.lock"
|
|
27
|
+
]);
|
|
28
|
+
const MAX_MANIFEST_BYTES = 32 * 1024 * 1024;
|
|
29
|
+
function inside(candidate, root) {
|
|
30
|
+
const rel = relative(root, candidate);
|
|
31
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
32
|
+
}
|
|
33
|
+
function safeRelativePath(value) {
|
|
34
|
+
if (!value || value.includes("\\") || value.includes("\0") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) throw new Error(`Unsafe backup path: ${value}`);
|
|
35
|
+
if (value.split("/").some((segment) => !segment || segment === "." || segment === "..")) throw new Error(`Unsafe backup path: ${value}`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function deriveKey(passphrase, salt) {
|
|
39
|
+
if (passphrase.length < 12) throw new Error("Backup passphrase must have at least 12 characters");
|
|
40
|
+
return scryptSync(passphrase, salt, 32, {
|
|
41
|
+
N: 32768,
|
|
42
|
+
r: 8,
|
|
43
|
+
p: 1,
|
|
44
|
+
maxmem: 64 * 1024 * 1024
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function encryptBytes(plain, key) {
|
|
48
|
+
const nonce = randomBytes(12);
|
|
49
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
50
|
+
return {
|
|
51
|
+
data: Buffer.concat([cipher.update(plain), cipher.final()]),
|
|
52
|
+
nonce: nonce.toString("base64"),
|
|
53
|
+
tag: cipher.getAuthTag().toString("base64")
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function decryptBytes(ciphertext, key, nonce, tag) {
|
|
57
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "base64"));
|
|
58
|
+
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
|
59
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
60
|
+
}
|
|
61
|
+
async function collectFiles(root) {
|
|
62
|
+
const files = [];
|
|
63
|
+
const excluded = [];
|
|
64
|
+
async function walk(directory, prefix = "") {
|
|
65
|
+
for (const item of await readdir(directory, { withFileTypes: true })) {
|
|
66
|
+
const rel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
67
|
+
if (!prefix && (OMIT_TOP_LEVEL.has(item.name) || OMIT_FILES.has(item.name))) {
|
|
68
|
+
excluded.push(rel);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!prefix && /^xopc\.db(?:-wal|-shm|-journal)?$/.test(item.name)) {
|
|
72
|
+
if (item.name !== "xopc.db") excluded.push(rel);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const source = join(directory, item.name);
|
|
76
|
+
if (item.isSymbolicLink()) throw new Error(`Backup contains a symbolic link: ${rel}`);
|
|
77
|
+
if (item.isDirectory()) await walk(source, rel);
|
|
78
|
+
else if (item.isFile()) files.push(rel);
|
|
79
|
+
else throw new Error(`Backup contains an unsupported file type: ${rel}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await walk(root);
|
|
83
|
+
return {
|
|
84
|
+
files,
|
|
85
|
+
excluded
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function encryptFile(source, destination, key, path) {
|
|
89
|
+
const handle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
90
|
+
try {
|
|
91
|
+
const before = await handle.stat();
|
|
92
|
+
if (!before.isFile()) throw new Error(`Backup entry is not a regular file: ${path}`);
|
|
93
|
+
const nonce = randomBytes(12);
|
|
94
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
95
|
+
const digest = createHash("sha256");
|
|
96
|
+
let bytes = 0;
|
|
97
|
+
const hashStream = new Transform({ transform(chunk, _encoding, callback) {
|
|
98
|
+
bytes += chunk.length;
|
|
99
|
+
digest.update(chunk);
|
|
100
|
+
callback(null, chunk);
|
|
101
|
+
} });
|
|
102
|
+
await pipeline(handle.createReadStream({ autoClose: false }), hashStream, cipher, createWriteStream(destination, {
|
|
103
|
+
flags: "wx",
|
|
104
|
+
mode: 384
|
|
105
|
+
}));
|
|
106
|
+
const after = await handle.stat();
|
|
107
|
+
const current = await lstat(source);
|
|
108
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || bytes !== before.size || !current.isFile() || before.ino !== current.ino || before.dev !== current.dev) throw new Error(`File changed during backup: ${path}`);
|
|
109
|
+
return {
|
|
110
|
+
path,
|
|
111
|
+
blob: basename(destination),
|
|
112
|
+
bytes,
|
|
113
|
+
sha256: digest.digest("hex"),
|
|
114
|
+
nonce: nonce.toString("base64"),
|
|
115
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
116
|
+
mode: before.mode & 511
|
|
117
|
+
};
|
|
118
|
+
} finally {
|
|
119
|
+
await handle.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function readArchive(archivePath, passphrase) {
|
|
123
|
+
const rawHeader = await readFile(join(archivePath, "header.json"), "utf8");
|
|
124
|
+
const header = JSON.parse(rawHeader);
|
|
125
|
+
if (header.format !== FORMAT || header.version !== VERSION) throw new Error("Unsupported backup format");
|
|
126
|
+
const key = deriveKey(passphrase, Buffer.from(header.salt, "base64"));
|
|
127
|
+
const encrypted = await readFile(join(archivePath, "manifest.enc"));
|
|
128
|
+
if (encrypted.length > MAX_MANIFEST_BYTES) throw new Error("Backup manifest is too large");
|
|
129
|
+
const manifest = JSON.parse(decryptBytes(encrypted, key, header.manifestNonce, header.manifestTag).toString("utf8"));
|
|
130
|
+
if (manifest.format !== FORMAT || manifest.version !== VERSION || !Array.isArray(manifest.entries)) throw new Error("Invalid backup manifest");
|
|
131
|
+
const seen = /* @__PURE__ */ new Set();
|
|
132
|
+
for (const entry of manifest.entries) {
|
|
133
|
+
safeRelativePath(entry.path);
|
|
134
|
+
if (!/^[0-9a-f-]{36}\.enc$/.test(entry.blob) || seen.has(entry.path)) throw new Error("Invalid backup entry");
|
|
135
|
+
if (!Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || !/^[0-9a-f]{64}$/.test(entry.sha256)) throw new Error("Invalid backup entry metadata");
|
|
136
|
+
seen.add(entry.path);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
manifest,
|
|
140
|
+
key
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function decryptEntry(archivePath, entry, key, destination) {
|
|
144
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(entry.nonce, "base64"));
|
|
145
|
+
decipher.setAuthTag(Buffer.from(entry.tag, "base64"));
|
|
146
|
+
const hash = createHash("sha256");
|
|
147
|
+
let bytes = 0;
|
|
148
|
+
const check = new Transform({ transform(chunk, _encoding, callback) {
|
|
149
|
+
bytes += chunk.length;
|
|
150
|
+
hash.update(chunk);
|
|
151
|
+
callback(null, chunk);
|
|
152
|
+
} });
|
|
153
|
+
const sink = destination ? createWriteStream(destination, {
|
|
154
|
+
flags: "wx",
|
|
155
|
+
mode: 384
|
|
156
|
+
}) : new Transform({ transform(_chunk, _encoding, callback) {
|
|
157
|
+
callback();
|
|
158
|
+
} });
|
|
159
|
+
await pipeline(createReadStream(join(archivePath, "blobs", entry.blob)), decipher, check, sink);
|
|
160
|
+
if (bytes !== entry.bytes || hash.digest("hex") !== entry.sha256) throw new Error(`Backup checksum mismatch: ${entry.path}`);
|
|
161
|
+
}
|
|
162
|
+
async function createStateBackup(input) {
|
|
163
|
+
const root = resolve(input.stateDir);
|
|
164
|
+
const output = resolve(input.output);
|
|
165
|
+
if (inside(output, root)) throw new Error("Backup output must be outside the state directory");
|
|
166
|
+
if (!(await lstat(root)).isDirectory()) throw new Error("State path is not a directory");
|
|
167
|
+
const databasePath = join(root, "xopc.db");
|
|
168
|
+
if (!(await lstat(databasePath)).isFile()) throw new Error("State database not found");
|
|
169
|
+
assertDatabaseOffline(databasePath);
|
|
170
|
+
const { DatabaseSync, backup } = requireNodeSqlite();
|
|
171
|
+
const pathDb = new DatabaseSync(databasePath, { readOnly: true });
|
|
172
|
+
const configuredPaths = [...input.configuredPaths ?? []];
|
|
173
|
+
const configPath = resolve(input.configPath ?? join(root, "xopc.json"));
|
|
174
|
+
try {
|
|
175
|
+
if (inside(configPath, root)) {
|
|
176
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
177
|
+
if (Array.isArray(config.agents?.list)) configuredPaths.push(...config.agents.list.map((agent) => agent.workspace).filter((path) => typeof path === "string"));
|
|
178
|
+
}
|
|
179
|
+
if (pathDb.prepare("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='agents'").get()) {
|
|
180
|
+
const rows = pathDb.prepare("SELECT workspace_override FROM agents WHERE deleted_at IS NULL AND workspace_override IS NOT NULL").all();
|
|
181
|
+
configuredPaths.push(...rows.map((row) => row.workspace_override));
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
pathDb.close();
|
|
185
|
+
}
|
|
186
|
+
const external = [...new Set(configuredPaths.map((path) => resolve(expandWorkspacePathString(path))).filter((path) => !inside(path, root)))];
|
|
187
|
+
if (external.length) throw new Error(`External paths are not included in this backup: ${external.join(", ")}`);
|
|
188
|
+
const { files, excluded } = await collectFiles(root);
|
|
189
|
+
const salt = randomBytes(32);
|
|
190
|
+
const key = deriveKey(input.passphrase, salt);
|
|
191
|
+
const scratch = await mkdtemp(join(tmpdir(), "xopc-backup-"));
|
|
192
|
+
let archiveCreated = false;
|
|
193
|
+
try {
|
|
194
|
+
await mkdir(output, { mode: 448 });
|
|
195
|
+
archiveCreated = true;
|
|
196
|
+
await mkdir(join(output, "blobs"), { mode: 448 });
|
|
197
|
+
const snapshot = join(scratch, "xopc.db");
|
|
198
|
+
const source = new DatabaseSync(databasePath, { readOnly: true });
|
|
199
|
+
try {
|
|
200
|
+
await backup(source, snapshot);
|
|
201
|
+
} finally {
|
|
202
|
+
source.close();
|
|
203
|
+
}
|
|
204
|
+
const checked = new DatabaseSync(snapshot, { readOnly: true });
|
|
205
|
+
let schemaVersion = 0;
|
|
206
|
+
try {
|
|
207
|
+
if (checked.prepare("PRAGMA integrity_check").get().integrity_check !== "ok") throw new Error("Database snapshot failed integrity check");
|
|
208
|
+
schemaVersion = Number(checked.prepare("SELECT value FROM schema_meta WHERE key='schema_version'").get()?.value ?? 0);
|
|
209
|
+
} finally {
|
|
210
|
+
checked.close();
|
|
211
|
+
}
|
|
212
|
+
const entries = [];
|
|
213
|
+
for (const rel of ["xopc.db", ...files]) {
|
|
214
|
+
const sourcePath = rel === "xopc.db" ? snapshot : join(root, rel);
|
|
215
|
+
const blob = `${randomUUID()}.enc`;
|
|
216
|
+
entries.push(await encryptFile(sourcePath, join(output, "blobs", blob), key, rel));
|
|
217
|
+
}
|
|
218
|
+
const manifest = {
|
|
219
|
+
format: FORMAT,
|
|
220
|
+
version: VERSION,
|
|
221
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
222
|
+
schemaVersion,
|
|
223
|
+
entries,
|
|
224
|
+
excluded
|
|
225
|
+
};
|
|
226
|
+
const sealed = encryptBytes(Buffer.from(JSON.stringify(manifest)), key);
|
|
227
|
+
if (sealed.data.length > MAX_MANIFEST_BYTES) throw new Error("Backup manifest is too large");
|
|
228
|
+
await writeFile(join(output, "manifest.enc"), sealed.data, {
|
|
229
|
+
flag: "wx",
|
|
230
|
+
mode: 384
|
|
231
|
+
});
|
|
232
|
+
const header = {
|
|
233
|
+
format: FORMAT,
|
|
234
|
+
version: VERSION,
|
|
235
|
+
salt: salt.toString("base64"),
|
|
236
|
+
manifestNonce: sealed.nonce,
|
|
237
|
+
manifestTag: sealed.tag
|
|
238
|
+
};
|
|
239
|
+
await writeFile(join(output, "header.json"), `${JSON.stringify(header)}\n`, {
|
|
240
|
+
flag: "wx",
|
|
241
|
+
mode: 384
|
|
242
|
+
});
|
|
243
|
+
return manifest;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (archiveCreated) await rm(output, {
|
|
246
|
+
recursive: true,
|
|
247
|
+
force: true
|
|
248
|
+
});
|
|
249
|
+
throw error;
|
|
250
|
+
} finally {
|
|
251
|
+
key.fill(0);
|
|
252
|
+
await rm(scratch, {
|
|
253
|
+
recursive: true,
|
|
254
|
+
force: true
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function verifyStateBackup(archivePath, passphrase) {
|
|
259
|
+
const archive = resolve(archivePath);
|
|
260
|
+
const { manifest, key } = await readArchive(archive, passphrase);
|
|
261
|
+
const scratch = await mkdtemp(join(tmpdir(), "xopc-backup-verify-"));
|
|
262
|
+
try {
|
|
263
|
+
for (const entry of manifest.entries) await decryptEntry(archive, entry, key, entry.path === "xopc.db" ? join(scratch, "xopc.db") : void 0);
|
|
264
|
+
const { DatabaseSync } = requireNodeSqlite();
|
|
265
|
+
const db = new DatabaseSync(join(scratch, "xopc.db"), { readOnly: true });
|
|
266
|
+
try {
|
|
267
|
+
if (db.prepare("PRAGMA integrity_check").get().integrity_check !== "ok") throw new Error("Backup database failed integrity check");
|
|
268
|
+
} finally {
|
|
269
|
+
db.close();
|
|
270
|
+
}
|
|
271
|
+
return manifest;
|
|
272
|
+
} finally {
|
|
273
|
+
key.fill(0);
|
|
274
|
+
await rm(scratch, {
|
|
275
|
+
recursive: true,
|
|
276
|
+
force: true
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function restoreStateBackup(input) {
|
|
281
|
+
const archive = resolve(input.archivePath);
|
|
282
|
+
const target = resolve(input.target);
|
|
283
|
+
const { manifest, key } = await readArchive(archive, input.passphrase);
|
|
284
|
+
const parent = dirname(target);
|
|
285
|
+
let staging;
|
|
286
|
+
try {
|
|
287
|
+
await mkdir(parent, { recursive: true });
|
|
288
|
+
try {
|
|
289
|
+
await lstat(target);
|
|
290
|
+
throw new Error("Restore target already exists; choose a new directory");
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error.code !== "ENOENT") throw error;
|
|
293
|
+
}
|
|
294
|
+
staging = await mkdtemp(join(parent, `.xopc-restore-${randomUUID()}-`));
|
|
295
|
+
for (const entry of manifest.entries) {
|
|
296
|
+
const destination = join(staging, ...entry.path.split("/"));
|
|
297
|
+
if (!inside(destination, staging)) throw new Error("Backup path escapes target");
|
|
298
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
299
|
+
await decryptEntry(archive, entry, key, destination);
|
|
300
|
+
await chmod(destination, entry.mode & 511);
|
|
301
|
+
}
|
|
302
|
+
const { DatabaseSync } = requireNodeSqlite();
|
|
303
|
+
const db = new DatabaseSync(join(staging, "xopc.db"), { readOnly: true });
|
|
304
|
+
try {
|
|
305
|
+
if (db.prepare("PRAGMA integrity_check").get().integrity_check !== "ok") throw new Error("Restored database failed integrity check");
|
|
306
|
+
} finally {
|
|
307
|
+
db.close();
|
|
308
|
+
}
|
|
309
|
+
await rename(staging, target);
|
|
310
|
+
return manifest;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (staging) await rm(staging, {
|
|
313
|
+
recursive: true,
|
|
314
|
+
force: true
|
|
315
|
+
});
|
|
316
|
+
throw error;
|
|
317
|
+
} finally {
|
|
318
|
+
key.fill(0);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
export { createStateBackup, restoreStateBackup, verifyStateBackup };
|
|
@@ -5,6 +5,7 @@ import { registry } from "./registry.js";
|
|
|
5
5
|
const REGISTRY_COMMAND_MODULES = {
|
|
6
6
|
init: () => import("./commands/init.js"),
|
|
7
7
|
profile: () => import("./commands/profile.js"),
|
|
8
|
+
backup: () => import("./commands/backup.js"),
|
|
8
9
|
setup: () => import("./commands/setup.js"),
|
|
9
10
|
onboard: () => import("./commands/onboard.js"),
|
|
10
11
|
agent: () => import("./commands/agent.js"),
|
|
@@ -35,6 +35,10 @@ const ROOT_HELP_COMMANDS = [
|
|
|
35
35
|
name: "profile",
|
|
36
36
|
description: "Manage xopc state profiles (~/.xopc vs ~/.xopc-<name>)"
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
name: "backup",
|
|
40
|
+
description: "Create, verify, and restore encrypted offline state backups"
|
|
41
|
+
},
|
|
38
42
|
{
|
|
39
43
|
name: "onboard [options]",
|
|
40
44
|
description: "Interactive setup wizard for xopc (gateway uses schema defaults)"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { init_paths_state, resolveStateDir } from "../../config/paths-state.js";
|
|
2
|
+
import { init_paths, resolveConfigPath, resolveCredentialsDir } from "../../config/paths.js";
|
|
3
|
+
import { createStateBackup, restoreStateBackup, verifyStateBackup } from "../../backup/archive.js";
|
|
4
|
+
import { formatExamples, register } from "../registry.js";
|
|
5
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
//#region src/cli/commands/backup.ts
|
|
9
|
+
init_paths();
|
|
10
|
+
init_paths_state();
|
|
11
|
+
async function passphraseFromFile(path) {
|
|
12
|
+
const source = resolve(path);
|
|
13
|
+
const details = await lstat(source);
|
|
14
|
+
if (!details.isFile()) throw new Error("Passphrase path must be a regular file");
|
|
15
|
+
if (process.platform !== "win32" && (details.mode & 63) !== 0) throw new Error("Passphrase file must be readable only by its owner (chmod 600)");
|
|
16
|
+
return (await readFile(source, "utf8")).replace(/\r?\n$/, "");
|
|
17
|
+
}
|
|
18
|
+
function reportError(error) {
|
|
19
|
+
console.error(`Backup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
function createBackupCommand(ctx) {
|
|
23
|
+
const command = new Command("backup").description("Create, verify, and restore encrypted offline state backups").addHelpText("after", formatExamples([
|
|
24
|
+
"xopc backup create --output /secure/xopc-backup --passphrase-file /secure/passphrase",
|
|
25
|
+
"xopc backup verify /secure/xopc-backup --passphrase-file /secure/passphrase",
|
|
26
|
+
"xopc backup restore /secure/xopc-backup --target /new/.xopc --passphrase-file /secure/passphrase"
|
|
27
|
+
]));
|
|
28
|
+
command.command("create").requiredOption("--output <directory>", "New backup directory outside the state directory").requiredOption("--passphrase-file <path>", "File containing a passphrase of at least 12 characters").action(async (options) => {
|
|
29
|
+
try {
|
|
30
|
+
const rel = relative(resolveStateDir(), resolve(options.passphraseFile));
|
|
31
|
+
if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) throw new Error("Passphrase file must be outside the state directory");
|
|
32
|
+
const manifest = await createStateBackup({
|
|
33
|
+
stateDir: resolveStateDir(),
|
|
34
|
+
output: options.output,
|
|
35
|
+
passphrase: await passphraseFromFile(options.passphraseFile),
|
|
36
|
+
configPath: ctx.configPath,
|
|
37
|
+
configuredPaths: [
|
|
38
|
+
ctx.configPath || resolveConfigPath(),
|
|
39
|
+
resolveCredentialsDir(),
|
|
40
|
+
ctx.workspacePath
|
|
41
|
+
]
|
|
42
|
+
});
|
|
43
|
+
console.log(`Backup created: ${resolve(options.output)} (${manifest.entries.length} files, schema ${manifest.schemaVersion})`);
|
|
44
|
+
console.log("Backups contain private conversations and credentials. Keep both the archive and passphrase secure.");
|
|
45
|
+
console.log("This first version covers the state directory and rejects known external workspace, config, and credential paths.");
|
|
46
|
+
} catch (error) {
|
|
47
|
+
reportError(error);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
command.command("verify").argument("<directory>", "Backup directory").requiredOption("--passphrase-file <path>", "Passphrase file").action(async (directory, options) => {
|
|
51
|
+
try {
|
|
52
|
+
const manifest = await verifyStateBackup(directory, await passphraseFromFile(options.passphraseFile));
|
|
53
|
+
console.log(`Backup verified: ${manifest.entries.length} files, schema ${manifest.schemaVersion}`);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
reportError(error);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
command.command("restore").argument("<directory>", "Backup directory").requiredOption("--target <directory>", "New restore directory; must not exist").requiredOption("--passphrase-file <path>", "Passphrase file").action(async (directory, options) => {
|
|
59
|
+
try {
|
|
60
|
+
const manifest = await restoreStateBackup({
|
|
61
|
+
archivePath: directory,
|
|
62
|
+
target: options.target,
|
|
63
|
+
passphrase: await passphraseFromFile(options.passphraseFile)
|
|
64
|
+
});
|
|
65
|
+
console.log(`Backup restored: ${resolve(options.target)} (${manifest.entries.length} files)`);
|
|
66
|
+
console.log("Review external paths and provider/channel authorization before starting the restored Gateway.");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
reportError(error);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return command;
|
|
72
|
+
}
|
|
73
|
+
register({
|
|
74
|
+
id: "backup",
|
|
75
|
+
name: "backup",
|
|
76
|
+
description: "Create, verify, and restore encrypted offline state backups",
|
|
77
|
+
factory: createBackupCommand,
|
|
78
|
+
metadata: {
|
|
79
|
+
category: "maintenance",
|
|
80
|
+
examples: ["xopc backup create --output /secure/backup --passphrase-file /secure/passphrase"]
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
//#endregion
|
|
84
|
+
export {};
|
package/dist/src/cli/index.js
CHANGED
|
@@ -23,10 +23,11 @@ function isExtensionsDevCommand(command) {
|
|
|
23
23
|
}
|
|
24
24
|
function buildProgram(ctx) {
|
|
25
25
|
const program = new Command().name("xopc").description(ROOT_COMMAND_DESCRIPTION).version(version).option("--verbose", "Enable verbose logging", false).option("--config <path>", "Config file path").option("--workspace <path>", "Workspace directory");
|
|
26
|
-
program.hook("preAction", (
|
|
27
|
-
const next =
|
|
26
|
+
program.hook("preAction", (hookOwner, actionCommand) => {
|
|
27
|
+
const next = hookOwner.opts();
|
|
28
28
|
for (const k of Object.keys(parsedOpts)) delete parsedOpts[k];
|
|
29
29
|
Object.assign(parsedOpts, next);
|
|
30
|
+
if (actionCommand?.name() === "backup" || actionCommand?.parent?.name() === "backup") return;
|
|
30
31
|
bootstrapApplicationStateSync(ctx.configPath);
|
|
31
32
|
});
|
|
32
33
|
program.hook("postAction", async (_hookOwner, actionCommand) => {
|
|
@@ -73,6 +73,7 @@ function computerRecovery(code) {
|
|
|
73
73
|
case "COMPUTER_APP_NOT_AUTHORIZED": return "This application has not been authorized in the local Computer Use settings. Tell the user where to configure access. Do not request a chat approval or try another control route.";
|
|
74
74
|
case "COMPUTER_APP_NOT_RUNNING": return "Open with prepare=true only if the user requested launching the app; otherwise ask them to open it.";
|
|
75
75
|
case "COMPUTER_WINDOW_REQUIRED": return "No visible target window. If the task permits bringing the app forward, open with prepare=true; otherwise ask the user to show the window. If preparation already failed, require a local state change before retrying.";
|
|
76
|
+
case "COMPUTER_DRIVER_BRING_TO_FRONT_REFUSED": return "The native driver refused to activate the app window. Ask the user to bring the intended window to the foreground locally, then discover and open it with prepare=false. Do not repeat prepare=true or use another control route.";
|
|
76
77
|
case "COMPUTER_WINDOW_AMBIGUOUS": return "Choose a returned windowRef only when its title matches the task. Otherwise ask the user using window titles, not IDs.";
|
|
77
78
|
case "COMPUTER_WINDOW_CHANGED": return "The selected window no longer exists. Discover and open again; never reuse its coordinates.";
|
|
78
79
|
case "COMPUTER_READ_ONLY": return "This session is read-only. Do not retry an action or reopen in control mode without user authorization.";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { assistText, streamTextAssist } from "../../../ai-assist/text-assist.js";
|
|
2
1
|
import { createGatewayRouteLogger, logRouteError } from "../lib/route-logger.js";
|
|
2
|
+
import { assistText, streamTextAssist } from "../../../ai-assist/text-assist.js";
|
|
3
3
|
import { stringifySSEData } from "../sse-json.js";
|
|
4
4
|
import { streamSSE } from "hono/streaming";
|
|
5
5
|
//#region src/gateway/hono/routes/ai-assist.ts
|
|
@@ -422,6 +422,15 @@ function registerSessionsRoutes(authenticated, deps) {
|
|
|
422
422
|
pagination: result.pagination
|
|
423
423
|
});
|
|
424
424
|
});
|
|
425
|
+
authenticated.get("/api/sessions/:key/execution-detail", async (c) => {
|
|
426
|
+
const blocked = ensureGatewayReadyForSessions(c, service, "sessions.history");
|
|
427
|
+
if (blocked) return blocked;
|
|
428
|
+
const turnId = c.req.query("turnId")?.trim();
|
|
429
|
+
if (!turnId || turnId.length > 256) return c.json({ error: "Invalid turn id" }, 400);
|
|
430
|
+
c.header("Cache-Control", "no-store");
|
|
431
|
+
const detail = await service.sessions.getExecutionDetail(c.req.param("key"), turnId);
|
|
432
|
+
return detail ? c.json({ detail }) : c.json({ error: "Execution detail not found" }, 404);
|
|
433
|
+
});
|
|
425
434
|
authenticated.get("/api/sessions/:key/history", async (c) => {
|
|
426
435
|
const blocked = ensureGatewayReadyForSessions(c, service, "sessions.history");
|
|
427
436
|
if (blocked) return blocked;
|
|
@@ -72,11 +72,10 @@ export declare class GatewaySessionsApi {
|
|
|
72
72
|
hasMore: boolean;
|
|
73
73
|
before?: string;
|
|
74
74
|
nextBeforeCursor?: string;
|
|
75
|
-
revision
|
|
76
|
-
/** List conversations whose stored type is workflow-subagent. */
|
|
77
|
-
?: number;
|
|
75
|
+
revision?: number;
|
|
78
76
|
};
|
|
79
77
|
}>;
|
|
78
|
+
getExecutionDetail(key: string, turnId: string): Promise<import("../../session/execution-detail.js").PublicExecutionDetail>;
|
|
80
79
|
getTimeline(key: string): Promise<import("../../session/transcript-outline.js").SessionTimelineItem[]>;
|
|
81
80
|
getTranscriptWindow(key: string, options: {
|
|
82
81
|
rowNumber: number;
|
|
@@ -5,6 +5,7 @@ import { transcriptRowsToClientHistory } from "../../session/client-history.js";
|
|
|
5
5
|
import "../../agent/mcp/bundle-mcp-tools.js";
|
|
6
6
|
import { collectMediaUrisFromValues, deleteMediaUris } from "../../media/session-references.js";
|
|
7
7
|
import { withModelConfigLock } from "../../session/model-config-lock.js";
|
|
8
|
+
import { publicExecutionDetail } from "../../session/execution-detail.js";
|
|
8
9
|
import { getDistinctSessionChatIds } from "./session-chat-ids.js";
|
|
9
10
|
import { performSessionReset } from "../session-reset-service.js";
|
|
10
11
|
import { randomUUID } from "node:crypto";
|
|
@@ -93,6 +94,10 @@ var GatewaySessionsApi = class {
|
|
|
93
94
|
getMessagePage(key, options) {
|
|
94
95
|
return this.opts.sessionIndex.getSessionMessagePage(key, options);
|
|
95
96
|
}
|
|
97
|
+
async getExecutionDetail(key, turnId) {
|
|
98
|
+
if (!await this.opts.sessionIndex.getSessionMetadata(key)) return null;
|
|
99
|
+
return publicExecutionDetail(transcriptRowsToClientHistory(await this.opts.sessionIndex.getStore().loadTranscriptHistoryRows(key)), turnId);
|
|
100
|
+
}
|
|
96
101
|
async getTimeline(key) {
|
|
97
102
|
if (!await this.opts.sessionIndex.getSessionMetadata(key)) return null;
|
|
98
103
|
return buildSessionTimeline(await this.opts.sessionIndex.getStore().loadTranscriptHistoryRows(key));
|
|
@@ -4,7 +4,7 @@ import { getSqliteDatabase, init_transaction } from "../storage/sqlite/transacti
|
|
|
4
4
|
import { hasGatewayScope, init_gateway_scopes } from "./security/gateway-scopes.js";
|
|
5
5
|
import { TaskRepository, init_task_repository } from "../tasks/task-repository.js";
|
|
6
6
|
import { getSessionMetadata } from "../storage/sqlite/session-read-repository.js";
|
|
7
|
-
import { init_session_repository } from "../storage/sqlite/session-repository.js";
|
|
7
|
+
import { getCurrentTranscriptId, init_session_repository } from "../storage/sqlite/session-repository.js";
|
|
8
8
|
import { getSessionConfig, init_config_repository } from "../storage/sqlite/config-repository.js";
|
|
9
9
|
import { TaskConversationRepository } from "../tasks/task-conversation-repository.js";
|
|
10
10
|
import { getExecutionEnvironmentForSession } from "../execution-environments/subject.js";
|
|
@@ -24,6 +24,94 @@ init_logger();
|
|
|
24
24
|
init_gateway_scopes();
|
|
25
25
|
const log = createLogger("SessionContextSummary");
|
|
26
26
|
const SOURCE_LIMIT = 20;
|
|
27
|
+
const RECENT_SOURCE_KINDS = new Set([
|
|
28
|
+
"note",
|
|
29
|
+
"file",
|
|
30
|
+
"session",
|
|
31
|
+
"browser_tab",
|
|
32
|
+
"mcp_resource",
|
|
33
|
+
"browser_page",
|
|
34
|
+
"app_context"
|
|
35
|
+
]);
|
|
36
|
+
function parseRecentSources(value) {
|
|
37
|
+
if (!value) return [];
|
|
38
|
+
let rows;
|
|
39
|
+
try {
|
|
40
|
+
rows = JSON.parse(value);
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(rows)) return [];
|
|
45
|
+
return rows.slice(0, SOURCE_LIMIT + 1).flatMap((value) => {
|
|
46
|
+
if (!value || typeof value !== "object") return [];
|
|
47
|
+
const row = value;
|
|
48
|
+
if (!RECENT_SOURCE_KINDS.has(String(row.kind)) || typeof row.sourceId !== "string" || !row.sourceId) return [];
|
|
49
|
+
return [{
|
|
50
|
+
kind: row.kind,
|
|
51
|
+
id: row.sourceId,
|
|
52
|
+
...typeof row.title === "string" ? { title: row.title.slice(0, 240) } : {},
|
|
53
|
+
...row.fileKind === "file" || row.fileKind === "directory" ? { fileKind: row.fileKind } : {},
|
|
54
|
+
origins: [{ kind: "recent" }]
|
|
55
|
+
}];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function parseRecentAttachments(value) {
|
|
59
|
+
if (!value) return [];
|
|
60
|
+
let rows;
|
|
61
|
+
try {
|
|
62
|
+
rows = JSON.parse(value);
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(rows)) return [];
|
|
67
|
+
return rows.slice(0, SOURCE_LIMIT + 1).flatMap((value) => {
|
|
68
|
+
if (!value || typeof value !== "object") return [];
|
|
69
|
+
const row = value;
|
|
70
|
+
const id = typeof row.uri === "string" ? row.uri : typeof row.id === "string" ? row.id : void 0;
|
|
71
|
+
if (!id || !id.startsWith("media://")) return [];
|
|
72
|
+
return [{
|
|
73
|
+
kind: "attachment",
|
|
74
|
+
id,
|
|
75
|
+
...typeof row.name === "string" ? { title: row.name.slice(0, 240) } : {},
|
|
76
|
+
origins: [{ kind: "recent" }]
|
|
77
|
+
}];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Reads only reference metadata from the latest visible user turn and current queued input. */
|
|
81
|
+
function readRecentSources(conversationId) {
|
|
82
|
+
const transcriptId = getCurrentTranscriptId(conversationId);
|
|
83
|
+
if (!transcriptId) return [];
|
|
84
|
+
const db = getSqliteDatabase();
|
|
85
|
+
const transcript = db.prepare(`SELECT
|
|
86
|
+
(SELECT json_group_array(json_object(
|
|
87
|
+
'kind', json_extract(value, '$.kind'), 'sourceId', json_extract(value, '$.sourceId'),
|
|
88
|
+
'title', json_extract(value, '$.title'), 'fileKind', json_extract(value, '$.fileKind')
|
|
89
|
+
)) FROM json_each(json_extract(entry.payload_json, '$.metadata.sourceContexts'))
|
|
90
|
+
WHERE CAST(key AS INTEGER) < ?) AS refs,
|
|
91
|
+
(SELECT json_group_array(json_object(
|
|
92
|
+
'uri', json_extract(value, '$.uri'), 'name', json_extract(value, '$.name')
|
|
93
|
+
)) FROM json_each(json_extract(entry.payload_json, '$.media'))
|
|
94
|
+
WHERE CAST(key AS INTEGER) < ?) AS attachments
|
|
95
|
+
FROM transcript_entries entry
|
|
96
|
+
WHERE transcript_id = ? AND entry_kind = 'message' AND role = 'user'
|
|
97
|
+
AND json_extract(payload_json, '$.metadata.hiddenFromClient') IS NOT 1
|
|
98
|
+
ORDER BY seq DESC LIMIT 1`).get(SOURCE_LIMIT + 1, SOURCE_LIMIT + 1, transcriptId);
|
|
99
|
+
const latest = db.prepare(`SELECT
|
|
100
|
+
(SELECT json_group_array(json_object(
|
|
101
|
+
'kind', json_extract(value, '$.kind'), 'sourceId', json_extract(value, '$.sourceId'),
|
|
102
|
+
'title', json_extract(value, '$.title'), 'fileKind', json_extract(value, '$.fileKind')
|
|
103
|
+
)) FROM json_each(input.context_refs_json)
|
|
104
|
+
WHERE CAST(key AS INTEGER) < ?) AS refs,
|
|
105
|
+
(SELECT json_group_array(json_object(
|
|
106
|
+
'uri', json_extract(value, '$.uri'), 'name', json_extract(value, '$.name')
|
|
107
|
+
)) FROM json_each(input.attachments_json)
|
|
108
|
+
WHERE CAST(key AS INTEGER) < ?) AS attachments
|
|
109
|
+
FROM session_inputs input WHERE conversation_id = ? AND expected_transcript_id = ?
|
|
110
|
+
AND kind = 'message' AND status IN ('queued', 'running', 'injecting', 'interrupted')
|
|
111
|
+
AND json_extract(origin_json, '$.type') != 'system'
|
|
112
|
+
ORDER BY created_at_ms DESC LIMIT 1`).get(SOURCE_LIMIT + 1, SOURCE_LIMIT + 1, conversationId, transcriptId) ?? transcript;
|
|
113
|
+
return latest ? [...parseRecentSources(latest.refs), ...parseRecentAttachments(latest.attachments)] : [];
|
|
114
|
+
}
|
|
27
115
|
async function readEnvironment(config, conversationId, projectId) {
|
|
28
116
|
const bound = getExecutionEnvironmentForSession(conversationId);
|
|
29
117
|
const project = projectId ? new ProjectStore().get(projectId) : void 0;
|
|
@@ -150,11 +238,31 @@ async function getSessionContextSummary(config, conversationId, scopes) {
|
|
|
150
238
|
kind: "note",
|
|
151
239
|
id: row.note_id,
|
|
152
240
|
...row.found_id ? { title: row.title ?? void 0 } : { unavailable: true },
|
|
153
|
-
origins: [...row.from_session ? [{
|
|
154
|
-
kind: "session",
|
|
155
|
-
version: source?.version
|
|
156
|
-
}] : [], ...row.from_task ? [{ kind: "task" }] : []]
|
|
241
|
+
origins: [...row.from_session ? [{ kind: "session" }] : [], ...row.from_task ? [{ kind: "task" }] : []]
|
|
157
242
|
}));
|
|
243
|
+
const sources = new Map(summary.sources.map((source) => [`${source.kind}:${source.id}`, source]));
|
|
244
|
+
const noteTitle = getSqliteDatabase().prepare(`SELECT substr(title, 1, 240) AS title
|
|
245
|
+
FROM notes WHERE note_id = ? AND status != 'trashed'`);
|
|
246
|
+
for (const recent of readRecentSources(conversationId)) {
|
|
247
|
+
const key = `${recent.kind}:${recent.id}`;
|
|
248
|
+
const existing = sources.get(key);
|
|
249
|
+
if (existing) {
|
|
250
|
+
if (!existing.origins.some((origin) => origin.kind === "recent")) existing.origins.push({ kind: "recent" });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (recent.kind === "note") {
|
|
254
|
+
const found = noteTitle.get(recent.id);
|
|
255
|
+
if (found) recent.title = found.title;
|
|
256
|
+
else {
|
|
257
|
+
delete recent.title;
|
|
258
|
+
recent.unavailable = true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
sources.set(key, recent);
|
|
262
|
+
}
|
|
263
|
+
summary.sourcesHasMore ||= sources.size > SOURCE_LIMIT;
|
|
264
|
+
const priority = (item) => item.origins.some((origin) => origin.kind === "session") ? 0 : item.origins.some((origin) => origin.kind === "recent") ? 1 : 2;
|
|
265
|
+
summary.sources = [...sources.values()].sort((a, b) => priority(a) - priority(b)).slice(0, SOURCE_LIMIT);
|
|
158
266
|
} catch (err) {
|
|
159
267
|
unavailable("sources", err);
|
|
160
268
|
}
|