@openchambery/web 1.19.21-beta.1 → 2.0.0-beta.1
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/README.md +13 -11
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +165 -0
- package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
- package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
- package/dist/assets/{DiagramView-Bh06xinl.js → DiagramView-xuSJTnv7.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
- package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
- package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
- package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
- package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
- package/dist/assets/{MultiRunWindow-CjCw1X4n.js → MultiRunWindow-ChZGob0v.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
- package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
- package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
- package/dist/assets/{SettingsWindow-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
- package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
- package/dist/assets/{appThemeRegistry-BKc-qXbQ.js → appThemeRegistry-COGfxTFV.js} +1 -1
- package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
- package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
- package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
- package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
- package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
- package/dist/assets/es-c52LTHHe.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-BTkP7aYI.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
- package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
- package/dist/assets/gitApi-ButwzQ0N.js +9 -0
- package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
- package/dist/assets/ja-C8Ikun3Z.js +15 -0
- package/dist/assets/ko-ClFys5o2.js +15 -0
- package/dist/assets/main-CCE_4y5F.js +2 -0
- package/dist/assets/main-Dn9rpsgl.js +169 -0
- package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
- package/dist/assets/miniChat-BpjxVqgg.js +2 -0
- package/dist/assets/mobile-nMJCaNq1.js +2 -0
- package/dist/assets/multirun-BvSG94mW.js +4 -0
- package/dist/assets/pl-CnaKWN5F.js +15 -0
- package/dist/assets/platform-CWTIp02q.js +4 -0
- package/dist/assets/projectMeta-DOe49dM_.js +3 -0
- package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
- package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
- package/dist/assets/{runtimeConfig-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
- package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
- package/dist/assets/{sessionLookup-CbbzalcH.js → sessionLookup-gh-hVRIc.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
- package/dist/assets/src-BzX5UI-c.css +2 -0
- package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
- package/dist/assets/textarea-3XR8sUfy.js +1 -0
- package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
- package/dist/assets/uk-CaX0iqkB.js +15 -0
- package/dist/assets/{useAppFontEffects-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
- package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
- package/dist/assets/{useEffectiveDirectory-CzHynoi_.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
- package/dist/assets/{useSessionAutoCleanup-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/zh-CN-20MYEjha.js +15 -0
- package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +2 -2
- package/server/index.js +107 -0
- package/server/lib/assistants/DOCUMENTATION.md +18 -18
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/projection-history.test.js +207 -0
- package/server/lib/assistants/routes.js +5 -5
- package/server/lib/assistants/service.js +493 -123
- package/server/lib/assistants/service.test.js +203 -133
- package/server/lib/conversations/DOCUMENTATION.md +4 -4
- package/server/lib/conversations/service.js +86 -57
- package/server/lib/conversations/service.test.js +116 -100
- package/server/lib/event-stream/global-hub.js +2 -1
- package/server/lib/llm/DOCUMENTATION.md +71 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +37 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +421 -450
- package/server/lib/llm/generate.test.js +553 -487
- package/server/lib/llm/routes.js +6 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/opencode/DOCUMENTATION.md +28 -5
- package/server/lib/opencode/config-entity-routes.js +84 -13
- package/server/lib/opencode/config-entity-routes.test.js +41 -10
- package/server/lib/opencode/ensure-cli.js +239 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +193 -110
- package/server/lib/opencode/env-runtime.test.js +221 -17
- package/server/lib/opencode/feature-routes-runtime.js +37 -1
- package/server/lib/opencode/lifecycle.js +204 -70
- package/server/lib/opencode/lifecycle.test.js +376 -6
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +87 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/proxy.js +153 -16
- package/server/lib/opencode/proxy.test.js +120 -0
- package/server/lib/opencode/routes.js +25 -32
- package/server/lib/opencode/routes.test.js +51 -52
- package/server/lib/opencode/server-utils-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +6 -7
- package/server/lib/opencode/skill-routes.test.js +36 -5
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +49 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +331 -308
- package/server/lib/scheduled-tasks/runtime.test.js +530 -318
- package/server/lib/session-goal/DOCUMENTATION.md +65 -14
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +231 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +46 -4
- package/server/lib/session-goal/runtime.test.js +62 -1
- package/server/lib/session-index/sync-runtime.js +20 -17
- package/server/lib/session-index/sync-runtime.test.js +9 -8
- package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
- package/server/lib/session-metadata/routes.js +94 -0
- package/server/lib/session-metadata/routes.test.js +111 -0
- package/server/lib/session-metadata/session-metadata-store.js +235 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +204 -71
- package/server/lib/session-turn-pages/routes.test.js +285 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/small-model/DOCUMENTATION.md +5 -4
- package/server/lib/small-model/catalog.js +56 -6
- package/server/lib/small-model/catalog.test.js +47 -0
- package/server/lib/small-model/index.js +3 -3
- package/server/lib/small-model/opencode-session.js +50 -206
- package/server/lib/small-model/opencode-session.test.js +58 -88
- package/server/opencode-proxy.test.js +61 -12
- package/dist/assets/AssistantView-BtGYjSW5.js +0 -3
- package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
- package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
- package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
- package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
- package/dist/assets/es-rqMiRP7-.js +0 -15
- package/dist/assets/gitApi-CcIuekQ3.js +0 -7
- package/dist/assets/insertionBoundaries-BocNNP9Q.js +0 -1
- package/dist/assets/ja-Bgr8-dOg.js +0 -15
- package/dist/assets/ko-B_Gxh-0l.js +0 -15
- package/dist/assets/main-CFLmpNC5.js +0 -169
- package/dist/assets/main-Ckj_IpUy.js +0 -2
- package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
- package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
- package/dist/assets/multirun-BGUKIJys.js +0 -4
- package/dist/assets/pl-DW2XJGC4.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
- package/dist/assets/pt-BR-CIsanf4T.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
- package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
- package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
- package/dist/assets/src-rS3zCcsC.css +0 -2
- package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
- package/dist/assets/uk-BPmPEUYJ.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
- package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenChamber-owned session metadata.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode 2.x accepts `metadata` only when a session is created; the v1
|
|
5
|
+
* `PATCH /session/{id}` route is gone and nothing replaces it. Session goal
|
|
6
|
+
* progress (and later assist / pinned notes) live here: one JSON file per data
|
|
7
|
+
* dir, `{ [sessionID]: metadata }`, folded back onto the sessions the proxy
|
|
8
|
+
* serves so clients keep reading `session.metadata` where they always did.
|
|
9
|
+
*
|
|
10
|
+
* Writes are a JSON Merge Patch (RFC 7386): nested objects merge key by key and
|
|
11
|
+
* a `null` deletes. That is what the old PATCH did, and it is what keeps two
|
|
12
|
+
* features writing into the same `openchamber` namespace from erasing each
|
|
13
|
+
* other — goal mode saving progress must not drop an assist recap.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fsDefault from 'node:fs';
|
|
17
|
+
import pathDefault from 'node:path';
|
|
18
|
+
|
|
19
|
+
const METADATA_FILE_NAME = 'sessions-metadata.json';
|
|
20
|
+
|
|
21
|
+
const asNonEmptyString = (value) => {
|
|
22
|
+
if (typeof value !== 'string') return null;
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* RFC 7386 merge. Returns a new object; `null` in the patch removes the key,
|
|
31
|
+
* and a non-object patch value replaces whatever was there.
|
|
32
|
+
*/
|
|
33
|
+
export const mergeMetadataPatch = (current, patch) => {
|
|
34
|
+
const base = isPlainObject(current) ? { ...current } : {};
|
|
35
|
+
if (!isPlainObject(patch)) return base;
|
|
36
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
37
|
+
if (value === null) {
|
|
38
|
+
delete base[key];
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
base[key] = isPlainObject(value) ? mergeMetadataPatch(base[key], value) : value;
|
|
42
|
+
}
|
|
43
|
+
return base;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} options
|
|
48
|
+
* @param {string} options.dataDir OpenChamber data directory for this instance.
|
|
49
|
+
* @param {typeof fsDefault.promises} [options.fsPromises]
|
|
50
|
+
* @param {typeof pathDefault} [options.path]
|
|
51
|
+
* @param {() => number} [options.now]
|
|
52
|
+
*/
|
|
53
|
+
export const createSessionMetadataStore = ({
|
|
54
|
+
dataDir,
|
|
55
|
+
fsPromises = fsDefault.promises,
|
|
56
|
+
path = pathDefault,
|
|
57
|
+
now = Date.now,
|
|
58
|
+
}) => {
|
|
59
|
+
const filePath = path.join(dataDir, METADATA_FILE_NAME);
|
|
60
|
+
|
|
61
|
+
/** sessionID → metadata object. Authoritative once `loaded` is true. */
|
|
62
|
+
const entries = new Map();
|
|
63
|
+
let loaded = false;
|
|
64
|
+
let loadPromise = null;
|
|
65
|
+
/**
|
|
66
|
+
* Writes stay disabled until one load has told us what is already on disk.
|
|
67
|
+
* A failed read is not evidence that nothing is stored, and writing over a
|
|
68
|
+
* file we could not read would drop exactly the state we are protecting.
|
|
69
|
+
*/
|
|
70
|
+
let writable = false;
|
|
71
|
+
let writeChain = Promise.resolve();
|
|
72
|
+
|
|
73
|
+
const snapshot = () => Object.fromEntries(entries);
|
|
74
|
+
|
|
75
|
+
const parseStored = (raw) => {
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
if (!isPlainObject(parsed)) {
|
|
78
|
+
throw new Error('session metadata file is not a JSON object');
|
|
79
|
+
}
|
|
80
|
+
const result = new Map();
|
|
81
|
+
for (const [sessionID, value] of Object.entries(parsed)) {
|
|
82
|
+
const id = asNonEmptyString(sessionID);
|
|
83
|
+
// A non-object entry is not metadata; dropping it is better than handing
|
|
84
|
+
// a consumer something it will read fields off.
|
|
85
|
+
if (id && isPlainObject(value)) result.set(id, value);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const readFile = async () => {
|
|
91
|
+
let raw;
|
|
92
|
+
try {
|
|
93
|
+
raw = await fsPromises.readFile(filePath, 'utf8');
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// No file yet is the normal first run: nothing is stored, and writing is
|
|
96
|
+
// safe because there is no state to lose.
|
|
97
|
+
if (error?.code === 'ENOENT') return { ok: true, stored: new Map() };
|
|
98
|
+
console.warn('[session-metadata] could not read the session metadata file:', error?.message ?? error);
|
|
99
|
+
return { ok: false, stored: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
return { ok: true, stored: parseStored(raw) };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// Malformed bytes are kept for the user instead of being overwritten on
|
|
106
|
+
// the next write, and whatever this process already knows stays in memory
|
|
107
|
+
// rather than collapsing to "no metadata".
|
|
108
|
+
const backup = `${filePath}.corrupt-${now()}`;
|
|
109
|
+
await fsPromises.rename(filePath, backup).catch(() => undefined);
|
|
110
|
+
console.warn(
|
|
111
|
+
`[session-metadata] session metadata file was unreadable and was moved to ${backup}: ${error?.message ?? error}`,
|
|
112
|
+
);
|
|
113
|
+
return { ok: true, stored: new Map() };
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const load = () => {
|
|
118
|
+
if (!loadPromise) {
|
|
119
|
+
loadPromise = readFile().then((result) => {
|
|
120
|
+
if (result.ok) {
|
|
121
|
+
// Merge rather than replace: a write that happened while the first
|
|
122
|
+
// load was still running must survive it.
|
|
123
|
+
for (const [id, metadata] of result.stored) {
|
|
124
|
+
if (!entries.has(id)) entries.set(id, metadata);
|
|
125
|
+
}
|
|
126
|
+
loaded = true;
|
|
127
|
+
writable = true;
|
|
128
|
+
} else {
|
|
129
|
+
// Let a later call retry; until one succeeds the store answers reads
|
|
130
|
+
// from memory and refuses writes.
|
|
131
|
+
loadPromise = null;
|
|
132
|
+
}
|
|
133
|
+
return { ok: result.ok, entries: snapshot() };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return loadPromise;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const persist = () => {
|
|
140
|
+
const payload = JSON.stringify(snapshot());
|
|
141
|
+
const write = async () => {
|
|
142
|
+
await fsPromises.mkdir(dataDir, { recursive: true });
|
|
143
|
+
// Temp file in the same directory so the rename is atomic on one device:
|
|
144
|
+
// a reader sees either the previous file or the complete new one.
|
|
145
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
146
|
+
await fsPromises.writeFile(tmpPath, payload, 'utf8');
|
|
147
|
+
await fsPromises.rename(tmpPath, filePath);
|
|
148
|
+
};
|
|
149
|
+
// Serialized so two overlapping writes cannot interleave their renames.
|
|
150
|
+
const next = writeChain.then(write, write);
|
|
151
|
+
writeChain = next.catch(() => undefined);
|
|
152
|
+
return next;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const get = async (sessionID) => {
|
|
156
|
+
const id = asNonEmptyString(sessionID);
|
|
157
|
+
if (!id) return {};
|
|
158
|
+
await load();
|
|
159
|
+
return entries.get(id) ?? {};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Whether the store holds anything for the session (an empty object counts as nothing). */
|
|
163
|
+
const has = async (sessionID) => {
|
|
164
|
+
const id = asNonEmptyString(sessionID);
|
|
165
|
+
if (!id) return false;
|
|
166
|
+
await load();
|
|
167
|
+
return entries.has(id);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Applies a merge patch and returns the session's full metadata afterwards.
|
|
172
|
+
* A failed write rolls the memory back and throws, so a caller never believes
|
|
173
|
+
* it saved something it did not.
|
|
174
|
+
*/
|
|
175
|
+
const setSessionMetadata = async (sessionID, patch) => {
|
|
176
|
+
const id = asNonEmptyString(sessionID);
|
|
177
|
+
if (!id) throw new Error('a session id is required to store session metadata');
|
|
178
|
+
if (!isPlainObject(patch)) throw new Error('a session metadata patch must be an object');
|
|
179
|
+
|
|
180
|
+
const loadResult = await load();
|
|
181
|
+
if (!loadResult.ok || !writable) {
|
|
182
|
+
throw new Error('session metadata is unavailable: its file could not be read');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const had = entries.has(id);
|
|
186
|
+
const previous = entries.get(id);
|
|
187
|
+
const merged = mergeMetadataPatch(previous, patch);
|
|
188
|
+
if (Object.keys(merged).length === 0) entries.delete(id);
|
|
189
|
+
else entries.set(id, merged);
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await persist();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (had) entries.set(id, previous);
|
|
195
|
+
else entries.delete(id);
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return merged;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const removeSession = async (sessionID) => {
|
|
203
|
+
const id = asNonEmptyString(sessionID);
|
|
204
|
+
if (!id) return false;
|
|
205
|
+
await load();
|
|
206
|
+
if (!writable || !entries.has(id)) return false;
|
|
207
|
+
const previous = entries.get(id);
|
|
208
|
+
entries.delete(id);
|
|
209
|
+
try {
|
|
210
|
+
await persist();
|
|
211
|
+
} catch (error) {
|
|
212
|
+
entries.set(id, previous);
|
|
213
|
+
console.warn('[session-metadata] failed to drop session metadata:', error?.message ?? error);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const getAll = async () => {
|
|
220
|
+
await load();
|
|
221
|
+
return snapshot();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
load,
|
|
226
|
+
get,
|
|
227
|
+
getAll,
|
|
228
|
+
list: getAll,
|
|
229
|
+
isLoaded: () => loaded,
|
|
230
|
+
has,
|
|
231
|
+
setSessionMetadata,
|
|
232
|
+
removeSession,
|
|
233
|
+
filePath,
|
|
234
|
+
};
|
|
235
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { createSessionMetadataStore, mergeMetadataPatch } from './session-metadata-store.js';
|
|
7
|
+
|
|
8
|
+
const tempDirs = [];
|
|
9
|
+
|
|
10
|
+
const makeDataDir = () => {
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-metadata-'));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const readFile = (dataDir) => fs.readFileSync(path.join(dataDir, 'sessions-metadata.json'), 'utf8');
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
while (tempDirs.length > 0) {
|
|
20
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('mergeMetadataPatch', () => {
|
|
26
|
+
it('merges nested objects key by key instead of replacing them', () => {
|
|
27
|
+
const current = { openchamber: { goal: { id: 'g1', status: 'active' }, assist: { recap: 'r' } } };
|
|
28
|
+
const merged = mergeMetadataPatch(current, { openchamber: { goal: { status: 'complete' } } });
|
|
29
|
+
|
|
30
|
+
expect(merged).toEqual({
|
|
31
|
+
openchamber: { goal: { id: 'g1', status: 'complete' }, assist: { recap: 'r' } },
|
|
32
|
+
});
|
|
33
|
+
// The input is untouched: callers keep whatever they already held.
|
|
34
|
+
expect(current.openchamber.goal.status).toBe('active');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('deletes a key when its patch value is null', () => {
|
|
38
|
+
expect(mergeMetadataPatch({ a: 1, b: 2 }, { b: null })).toEqual({ a: 1 });
|
|
39
|
+
expect(mergeMetadataPatch({ openchamber: { goal: {}, assist: {} } }, { openchamber: { assist: null } }))
|
|
40
|
+
.toEqual({ openchamber: { goal: {} } });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('replaces arrays and scalars rather than merging into them', () => {
|
|
44
|
+
expect(mergeMetadataPatch({ pins: ['a', 'b'] }, { pins: ['c'] })).toEqual({ pins: ['c'] });
|
|
45
|
+
expect(mergeMetadataPatch({ a: { nested: true } }, { a: 'flat' })).toEqual({ a: 'flat' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('treats a missing or non-object base as empty', () => {
|
|
49
|
+
expect(mergeMetadataPatch(undefined, { a: 1 })).toEqual({ a: 1 });
|
|
50
|
+
expect(mergeMetadataPatch('nope', { a: 1 })).toEqual({ a: 1 });
|
|
51
|
+
expect(mergeMetadataPatch({ a: 1 }, 'nope')).toEqual({ a: 1 });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('createSessionMetadataStore', () => {
|
|
56
|
+
it('starts empty when the file does not exist', async () => {
|
|
57
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
58
|
+
await expect(store.getAll()).resolves.toEqual({});
|
|
59
|
+
await expect(store.get('ses_1')).resolves.toEqual({});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('stores a patch, persists it, and reads it back in a fresh store', async () => {
|
|
63
|
+
const dataDir = makeDataDir();
|
|
64
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
65
|
+
|
|
66
|
+
const merged = await store.setSessionMetadata('ses_1', { openchamber: { goal: { id: 'g1' } } });
|
|
67
|
+
expect(merged).toEqual({ openchamber: { goal: { id: 'g1' } } });
|
|
68
|
+
expect(JSON.parse(readFile(dataDir))).toEqual({ ses_1: { openchamber: { goal: { id: 'g1' } } } });
|
|
69
|
+
|
|
70
|
+
const reopened = createSessionMetadataStore({ dataDir });
|
|
71
|
+
await expect(reopened.get('ses_1')).resolves.toEqual({ openchamber: { goal: { id: 'g1' } } });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps a neighbouring namespace when another feature writes', async () => {
|
|
75
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
76
|
+
await store.setSessionMetadata('ses_1', { openchamber: { assist: { recap: 'done' } } });
|
|
77
|
+
const merged = await store.setSessionMetadata('ses_1', { openchamber: { goal: { status: 'active' } } });
|
|
78
|
+
|
|
79
|
+
expect(merged).toEqual({ openchamber: { assist: { recap: 'done' }, goal: { status: 'active' } } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('deletes a key with a null patch value and drops a session that empties out', async () => {
|
|
83
|
+
const dataDir = makeDataDir();
|
|
84
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
85
|
+
await store.setSessionMetadata('ses_1', { openchamber: { goal: { id: 'g1' } } });
|
|
86
|
+
|
|
87
|
+
await expect(store.setSessionMetadata('ses_1', { openchamber: null })).resolves.toEqual({});
|
|
88
|
+
await expect(store.getAll()).resolves.toEqual({});
|
|
89
|
+
expect(JSON.parse(readFile(dataDir))).toEqual({});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('scopes metadata per session', async () => {
|
|
93
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
94
|
+
await store.setSessionMetadata('ses_1', { a: 1 });
|
|
95
|
+
await store.setSessionMetadata('ses_2', { b: 2 });
|
|
96
|
+
|
|
97
|
+
await expect(store.getAll()).resolves.toEqual({ ses_1: { a: 1 }, ses_2: { b: 2 } });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects a blank session id and a non-object patch', async () => {
|
|
101
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
102
|
+
await expect(store.setSessionMetadata(' ', { a: 1 })).rejects.toThrow(/session id is required/);
|
|
103
|
+
await expect(store.setSessionMetadata('ses_1', 'nope')).rejects.toThrow(/must be an object/);
|
|
104
|
+
await expect(store.setSessionMetadata('ses_1', ['a'])).rejects.toThrow(/must be an object/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('writes atomically: the visible file is never a partial payload', async () => {
|
|
108
|
+
const dataDir = makeDataDir();
|
|
109
|
+
const seen = [];
|
|
110
|
+
const fsPromises = {
|
|
111
|
+
...fs.promises,
|
|
112
|
+
writeFile: async (target, payload, encoding) => {
|
|
113
|
+
// The visible file must still be the previous one at this point.
|
|
114
|
+
seen.push(fs.existsSync(path.join(dataDir, 'sessions-metadata.json')) ? readFile(dataDir) : null);
|
|
115
|
+
return fs.promises.writeFile(target, payload, encoding);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
119
|
+
|
|
120
|
+
await store.setSessionMetadata('ses_1', { a: 1 });
|
|
121
|
+
await store.setSessionMetadata('ses_2', { b: 2 });
|
|
122
|
+
|
|
123
|
+
expect(seen).toEqual([null, JSON.stringify({ ses_1: { a: 1 } })]);
|
|
124
|
+
expect(JSON.parse(readFile(dataDir))).toEqual({ ses_1: { a: 1 }, ses_2: { b: 2 } });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('treats a malformed file as empty, keeps a backup, and still accepts writes', async () => {
|
|
128
|
+
const dataDir = makeDataDir();
|
|
129
|
+
fs.writeFileSync(path.join(dataDir, 'sessions-metadata.json'), '{ not json', 'utf8');
|
|
130
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
131
|
+
|
|
132
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
133
|
+
await expect(store.getAll()).resolves.toEqual({});
|
|
134
|
+
await expect(store.setSessionMetadata('ses_1', { a: 1 })).resolves.toEqual({ a: 1 });
|
|
135
|
+
expect(JSON.parse(readFile(dataDir))).toEqual({ ses_1: { a: 1 } });
|
|
136
|
+
expect(fs.readdirSync(dataDir).some((name) => name.includes('sessions-metadata.json.'))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('drops entries that are not metadata objects', async () => {
|
|
140
|
+
const dataDir = makeDataDir();
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
path.join(dataDir, 'sessions-metadata.json'),
|
|
143
|
+
JSON.stringify({ ses_ok: { a: 1 }, ses_list: ['a'], ses_text: 'nope', ses_null: null }),
|
|
144
|
+
'utf8',
|
|
145
|
+
);
|
|
146
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
147
|
+
await expect(store.getAll()).resolves.toEqual({ ses_ok: { a: 1 } });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('refuses to write when the file could not be read, so unknown state is never overwritten', async () => {
|
|
151
|
+
const dataDir = makeDataDir();
|
|
152
|
+
fs.writeFileSync(path.join(dataDir, 'sessions-metadata.json'), '{}', 'utf8');
|
|
153
|
+
const unreadable = Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
|
154
|
+
const fsPromises = {
|
|
155
|
+
...fs.promises,
|
|
156
|
+
readFile: async () => { throw unreadable; },
|
|
157
|
+
writeFile: async () => { throw new Error('must not write'); },
|
|
158
|
+
};
|
|
159
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
160
|
+
|
|
161
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
162
|
+
await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/could not be read/);
|
|
163
|
+
expect(readFile(dataDir)).toBe('{}');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rolls memory back and reports failure when persisting fails', async () => {
|
|
167
|
+
const dataDir = makeDataDir();
|
|
168
|
+
const fsPromises = { ...fs.promises, writeFile: async () => { throw new Error('disk full'); } };
|
|
169
|
+
|
|
170
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
171
|
+
await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow('disk full');
|
|
172
|
+
await expect(store.get('ses_1')).resolves.toEqual({});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('forgets a session on request', async () => {
|
|
176
|
+
const dataDir = makeDataDir();
|
|
177
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
178
|
+
await store.setSessionMetadata('ses_1', { a: 1 });
|
|
179
|
+
|
|
180
|
+
await expect(store.removeSession('ses_1')).resolves.toBe(true);
|
|
181
|
+
await expect(store.removeSession('ses_1')).resolves.toBe(false);
|
|
182
|
+
expect(JSON.parse(readFile(dataDir))).toEqual({});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -34,7 +34,7 @@ Official OpenCode pagination is message-count based and uses **opaque** cursors
|
|
|
34
34
|
| `scanLimit` | **omitted** | `10..200` when present | Optional override only. Host→OpenCode local page size; when omitted it resolves to `_inner_scanLimit`. Clients should omit this. |
|
|
35
35
|
| `before` | omitted | opaque string ≤ 8192 chars | Resume older history: host cursor (`oc1.…`) or raw OpenCode SDK cursor on first page |
|
|
36
36
|
| `directory` | omitted | path string | Forwarded to directory-scoped OpenCode client |
|
|
37
|
-
| `includeReasoning` | omitted (include) | strict `'false'` only disables | OpenChamber projection control. When `'false'`, drop `type=reasoning` parts from turn-page / reconcile / exact responses
|
|
37
|
+
| `includeReasoning` | omitted (include) | strict `'false'` only disables | OpenChamber projection control. When `'false'`, drop `type=reasoning` parts from turn-page / reconcile / exact responses. Host order is **normalize native SessionMessageInfo → slim/L1 bounds → reasoning strip** so `content`/`text` carriers cannot rebuild filtered parts or leak through `info` expansion. Missing or any other value keeps existing behavior (slim reasoning identity on turn pages; full parts on reconcile/exact). **Never forwarded to OpenCode.** Same contract on transcript-cache reads, official `session.messages` list proxy, and event SSE/WS send boundaries — see `event-stream/reasoning-projection.js`. |
|
|
38
38
|
|
|
39
39
|
Invalid `turns`, explicit invalid `scanLimit`, or `before` (malformed/stale host token, oversize) → HTTP 400.
|
|
40
40
|
|
|
@@ -82,7 +82,7 @@ Every turn-page response summarizes `tool`, `reasoning`, and `file` parts plus m
|
|
|
82
82
|
|---|---|---|
|
|
83
83
|
| **L1** | Turn pages, reconcile response pages, message/session SSE fan-out, exact `GET /api/session/:sessionID/message/:messageID` | Any `info.summary.diffs` **array** is projected to a **slim file list** `[{ file, status?, additions, deletions }]` plus additive markers `summary.diffCount: number` and `summary.hasDiffs: boolean`. Patch/before/after/from/to and other heavy bodies are stripped. Other lightweight summary fields are preserved. `diffCount` equals the slim array length; `hasDiffs === diffCount > 0`. Consumers may use either the slim array or the markers. |
|
|
84
84
|
| **L2** | `GET /api/openchamber/sessions/:sessionID/changes?messageID=` (no `file`) | Host reads official `session.message` locally, returns only `{ files: [{ file, status?, additions, deletions }] }`. Never returns patch/before/after/from/to or the full message envelope. |
|
|
85
|
-
| **L3** | Same route with `file=` | Host
|
|
85
|
+
| **L3** | Same route with `file=` | Host currently still reads `info.summary.diffs` from the exact message payload when present. **v2 `SessionMessageInfo` has no `summary.diffs`**, so L3 fails closed with upstream 502 until a parent follow-up lands an authoritative per-message diff source. Do **not** substitute working-tree / `vcs.diff` here. When diffs are available, returns `{ diff: SnapshotFileDiff }` for the exact `file` string match. No match → HTTP 404 `{ error: 'change file not found', code: 'change_not_found' }`. Older hosts may ignore `file=` and answer 200 `{ files }` (L2 only). |
|
|
86
86
|
|
|
87
87
|
Exact message GET keeps the official `{ info, parts }` response shape and only applies L1 summary projection so materialize/recovery never relays raw multi-megabyte patch bodies through the generic proxy. Abort/45s timeout semantics match the turn-page routes. Global `/api` auth still applies.
|
|
88
88
|
|
|
@@ -263,7 +263,7 @@ When the per-page budget fills before the gap is complete, the host returns `con
|
|
|
263
263
|
- `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `projectMessageDiffSummaries` (L1), `projectSlimParts`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
|
|
264
264
|
- `reconcile.service.js` — `encodeReconcileContinuation`, `decodeReconcileContinuation`, `createSessionReconcileService({ fetchPage, runtimeKey?, page/total budgets?, continuationSecret?, clock?, continuationTtlMs? })`
|
|
265
265
|
- `changes.service.js` — `createSessionChangesService({ fetchMessage, fetchDiff })` for L2/L3; injectable for unit tests
|
|
266
|
-
- `routes.js` — Express registration for turn-page, reconcile, Changes, and exact message GET; optional injected `sessionTurnPageService` / `sessionReconcileService` / `sessionChangesService` / `fetchExactMessage` for tests; default wires
|
|
266
|
+
- `routes.js` — Express registration for turn-page, reconcile, Changes, and exact message GET; optional injected `sessionTurnPageService` / `sessionReconcileService` / `sessionChangesService` / `fetchExactMessage` for tests; default wires `@opencode/client` `OpenCode.make` (`message.list` / `session.message`) via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`. Exact/not-found classification honors real client `_tag` (`MessageNotFoundError` / `SessionNotFoundError`) and preserves abort → HTTP 499 (never folds abort into 502).
|
|
267
267
|
- `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` / `changes.service.test.js` — unit contracts
|
|
268
268
|
|
|
269
269
|
## Registration
|
|
@@ -47,7 +47,7 @@ const PROCESS_CONTINUATION_SECRET = randomBytes(32);
|
|
|
47
47
|
const fail = (error) => ({ ok: false, error });
|
|
48
48
|
|
|
49
49
|
const recordId = (record) => {
|
|
50
|
-
const id = record?.info?.id;
|
|
50
|
+
const id = record?.info?.id ?? record?.id;
|
|
51
51
|
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
52
52
|
};
|
|
53
53
|
|
|
@@ -686,4 +686,20 @@ describe('createSessionReconcileService', () => {
|
|
|
686
686
|
expect(result.ok).toBe(true);
|
|
687
687
|
expect(result.resetRequired).toBe(true);
|
|
688
688
|
});
|
|
689
|
+
|
|
690
|
+
it('locates a SessionMessageInfo anchor by top-level id', async () => {
|
|
691
|
+
const fetchPage = vi.fn(async () => pageResult([
|
|
692
|
+
{ id: 'msg_u1', type: 'user', time: { created: 1 }, text: 'hi' },
|
|
693
|
+
{ id: 'msg_a1', type: 'assistant', time: { created: 2, completed: 3 }, content: [{ type: 'text', text: 'ok' }] },
|
|
694
|
+
], null));
|
|
695
|
+
const service = createSessionReconcileService(serviceOptions(fetchPage));
|
|
696
|
+
const result = await service.reconcile({
|
|
697
|
+
sessionID: 'ses_1',
|
|
698
|
+
directory: '/repo',
|
|
699
|
+
anchor: 'msg_u1',
|
|
700
|
+
});
|
|
701
|
+
expect(result.ok).toBe(true);
|
|
702
|
+
expect(result.anchorFound).toBe(true);
|
|
703
|
+
expect(result.records.map((entry) => entry.id)).toEqual(['msg_u1', 'msg_a1']);
|
|
704
|
+
});
|
|
689
705
|
});
|