ompchamber 3.4.0 → 3.6.0
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/client/index.html +1 -1
- package/dist/client/index.js +387 -383
- package/dist/client/static/js/index-7n838fz5.css +1 -0
- package/dist/client/static/js/{index-h05z90mw.js → index-qp2sfmn2.js} +614 -615
- package/package.json +1 -1
- package/src/cli/lib/commands/status.js +7 -0
- package/src/client/components/common/session-actions-menu/index.tsx +201 -0
- package/src/client/components/common/session-delete-modal/index.tsx +128 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
- package/src/client/components/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
- package/src/client/components/settings/ProjectSelectorDropdown.tsx +8 -2
- package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
- package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
- package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
- package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
- package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
- package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
- package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
- package/src/client/data/settings/command.ts +0 -40
- package/src/client/hooks/settings/workspace-roots.ts +99 -0
- package/src/client/hooks/ui/updates/index.ts +178 -0
- package/src/client/hooks/workspace/session-delete.ts +94 -0
- package/src/server/db.server.ts +41 -11
- package/src/server/index.ts +17 -1
- package/src/server/lib/btw/purge.server.ts +56 -0
- package/src/server/lib/db/client.ts +17 -0
- package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
- package/src/server/lib/db/compact-chat-overlay.ts +90 -0
- package/src/server/lib/fs/path-segment.test.ts +100 -0
- package/src/server/lib/fs/path-segment.ts +45 -0
- package/src/server/lib/lifecycle/fd-pressure.test.ts +66 -0
- package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
- package/src/server/lib/omp/config/commands.test.ts +167 -0
- package/src/server/lib/omp/config/commands.ts +195 -0
- package/src/server/lib/omp/config/scope.test.ts +49 -0
- package/src/server/lib/omp/config/scope.ts +65 -0
- package/src/server/lib/omp/config/skills-catalog.ts +35 -3
- package/src/server/lib/omp/config/skills.test.ts +105 -0
- package/src/server/lib/omp/config/skills.ts +260 -47
- package/src/server/lib/omp/rpc/process.ts +22 -14
- package/src/server/lib/omp/rpc/session-registry.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +45 -23
- package/src/server/lib/omp/session/delete.server.ts +136 -0
- package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
- package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
- package/src/server/lib/omp/session/merge-stored.ts +78 -11
- package/src/server/lib/omp/session/reader.ts +13 -0
- package/src/server/lib/omp/session/reload.server.test.ts +84 -0
- package/src/server/lib/omp/session/reload.server.ts +47 -0
- package/src/server/lib/omp/session/subagent-presence.ts +40 -9
- package/src/server/lib/terminal/liveness.server.ts +65 -0
- package/src/server/lib/terminal/runtime.server.ts +70 -117
- package/src/server/lib/terminal/session.server.ts +5 -2
- package/src/server/lib/terminal/store.ts +72 -0
- package/src/server/lib/updates/apply.ts +62 -33
- package/src/server/lib/updates/install.ts +9 -2
- package/src/server/lib/updates/omp.ts +37 -9
- package/src/server/lib/updates/single-flight.test.ts +61 -0
- package/src/server/lib/updates/single-flight.ts +68 -0
- package/src/server/routes/chat/session.ts +13 -3
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/omp/index.ts +0 -2
- package/src/server/routes/sessions/delete.test.ts +84 -0
- package/src/server/routes/sessions/delete.ts +83 -0
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/settings/commands.ts +102 -15
- package/src/server/routes/settings/skills.ts +165 -113
- package/src/server/routes/terminal/sessions.ts +1 -1
- package/src/server/routes/updates/apply.ts +101 -14
- package/src/shared/lib/chat/composer/client.ts +27 -18
- package/src/shared/lib/chat/composer/scope.test.ts +87 -0
- package/src/shared/lib/markdown/katex.ts +11 -1
- package/src/shared/lib/markdown/marked.ts +68 -13
- package/src/shared/lib/markdown/math.test.ts +53 -1
- package/src/shared/lib/updates/progress.test.ts +54 -0
- package/src/shared/lib/updates/progress.ts +50 -0
- package/src/shared/lib/workspace/session-state/store.ts +20 -0
- package/src/shared/types/settings/command.ts +15 -2
- package/src/shared/types/settings/skill.ts +13 -0
- package/dist/client/static/js/index-bsxxt89j.css +0 -1
- package/src/client/hooks/ui/updates.ts +0 -108
- package/src/server/routes/omp/commands.ts +0 -29
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deleting a session: the transcript on disk, its subagent artifacts, and every
|
|
8
|
+
* chamber-side row keyed to the session id.
|
|
9
|
+
*
|
|
10
|
+
* The transcript is authoritative — omp's own session listing is derived from
|
|
11
|
+
* the JSONL alone — so a delete that removed only the chamber's rows would see
|
|
12
|
+
* the session come straight back on the next sidebar scan. What "delete" means
|
|
13
|
+
* here, in one place:
|
|
14
|
+
*
|
|
15
|
+
* - the session JSONL, plus any `<name>.jsonl.bak-*` copy an earlier rewind
|
|
16
|
+
* left beside it (a full copy of the same transcript: leaving it behind
|
|
17
|
+
* would mean the conversation was not actually deleted);
|
|
18
|
+
* - its sibling artifacts directory, which holds the subagent transcripts;
|
|
19
|
+
* - the `archived_sessions`, `session_ui_state`, `session_stream_state`,
|
|
20
|
+
* `queued_messages` and `chat_sessions` rows for that id;
|
|
21
|
+
* - in mock mode the demo `sessions` row and its `files` tree.
|
|
22
|
+
*
|
|
23
|
+
* The caller owns the process: a live omp child MUST be destroyed before this
|
|
24
|
+
* runs, because omp flushes its session state on shutdown and would otherwise
|
|
25
|
+
* recreate the file this just removed. Side-question rows and workspaces are
|
|
26
|
+
* the btw module's business (`purgeBtwForSession`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import path from 'path';
|
|
31
|
+
import { getDb } from '@/server/db.server';
|
|
32
|
+
import type { DbClient } from '@/server/lib/db/client';
|
|
33
|
+
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
34
|
+
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
35
|
+
import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
36
|
+
import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths';
|
|
37
|
+
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
38
|
+
|
|
39
|
+
export interface SessionPurgeResult {
|
|
40
|
+
/** Something keyed to this id existed — a transcript, a mock session row, or
|
|
41
|
+
* a chamber row. False means the id resolved nowhere at all. */
|
|
42
|
+
found: boolean;
|
|
43
|
+
fileRemoved: boolean;
|
|
44
|
+
artifactsRemoved: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Chamber rows keyed by a session id. Every statement is a no-op when the id
|
|
49
|
+
* matches nothing, which is what makes one purge serve both modes: real
|
|
50
|
+
* sessions (omp UUIDs, present on disk only) and mock ones (numeric ids in
|
|
51
|
+
* `sessions`).
|
|
52
|
+
*
|
|
53
|
+
* `files` carries a FOREIGN KEY to `sessions` and `foreign_keys` is ON, so its
|
|
54
|
+
* children go before the session row or the delete is refused.
|
|
55
|
+
*/
|
|
56
|
+
const SESSION_ROW_DELETES: readonly (readonly [string, string])[] = [
|
|
57
|
+
['archived_sessions', 'session_id'],
|
|
58
|
+
['session_ui_state', 'session_id'],
|
|
59
|
+
['session_stream_state', 'session_id'],
|
|
60
|
+
['queued_messages', 'session_id'],
|
|
61
|
+
['chat_sessions', 'session_id'],
|
|
62
|
+
['files', 'session_id'],
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
async function purgeChamberRows(db: DbClient, sessionId: string): Promise<number> {
|
|
66
|
+
let changes = 0;
|
|
67
|
+
for (const [table, column] of SESSION_ROW_DELETES) {
|
|
68
|
+
const result = await db.run(`DELETE FROM ${table} WHERE ${column} = ?`, [sessionId]);
|
|
69
|
+
changes += result.changes ?? 0;
|
|
70
|
+
}
|
|
71
|
+
const mockRow = await db.run('DELETE FROM sessions WHERE id = ?', [sessionId]);
|
|
72
|
+
return changes + (mockRow.changes ?? 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Remove the transcript and the `.bak-*` copies beside it. */
|
|
76
|
+
async function removeTranscript(filePath: string): Promise<boolean> {
|
|
77
|
+
const existed = await Bun.file(filePath).exists();
|
|
78
|
+
await fs.promises.rm(filePath, { force: true });
|
|
79
|
+
const dir = path.dirname(filePath);
|
|
80
|
+
const prefix = `${path.basename(filePath)}.bak-`;
|
|
81
|
+
try {
|
|
82
|
+
for (const entry of await fs.promises.readdir(dir)) {
|
|
83
|
+
if (entry.startsWith(prefix)) await fs.promises.rm(path.join(dir, entry), { force: true });
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// A backup that cannot be listed is not worth failing a delete the
|
|
87
|
+
// transcript half of which already succeeded.
|
|
88
|
+
}
|
|
89
|
+
return existed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Purge everything the chamber and the agent directory hold for `sessionId`.
|
|
94
|
+
* `sessionId` must be a single safe path segment — it names the artifacts
|
|
95
|
+
* directory — which the route validates before calling.
|
|
96
|
+
*/
|
|
97
|
+
export async function purgeSessionData(sessionId: string): Promise<SessionPurgeResult> {
|
|
98
|
+
// Second gate, independent of the route's: an id that is not one segment
|
|
99
|
+
// would make `siblingDirForSession` and the btw purge resolve outside the
|
|
100
|
+
// session it names. Refusing here means a future caller that forgets the
|
|
101
|
+
// route's guard still cannot widen the blast radius.
|
|
102
|
+
if (!isSinglePathSegment(sessionId)) {
|
|
103
|
+
return { found: false, fileRemoved: false, artifactsRemoved: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const filePath = await findSessionFileById(sessionId);
|
|
107
|
+
let fileRemoved = false;
|
|
108
|
+
let artifactsRemoved = false;
|
|
109
|
+
if (filePath) {
|
|
110
|
+
fileRemoved = await removeTranscript(filePath);
|
|
111
|
+
const artifactsDir = siblingDirForSession(filePath);
|
|
112
|
+
// `stat`, not `Bun.file().exists()`: the latter answers for regular files
|
|
113
|
+
// only and reports a directory as absent, which would claim every session
|
|
114
|
+
// had no subagent transcripts.
|
|
115
|
+
artifactsRemoved = await fs.promises
|
|
116
|
+
.stat(artifactsDir)
|
|
117
|
+
.then((stats) => stats.isDirectory(), () => false);
|
|
118
|
+
await fs.promises.rm(artifactsDir, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const db = await getDb();
|
|
122
|
+
const rowChanges = await purgeChamberRows(db, sessionId);
|
|
123
|
+
|
|
124
|
+
// Both caches outlive the mutation by their own keys: the file list/scan pair
|
|
125
|
+
// is mtime-keyed (a delete bumps the parent directory, but the memo can still
|
|
126
|
+
// answer first) and the sidebar dataset is TTL'd, so without this the deleted
|
|
127
|
+
// row is served back to the refresh that follows the delete.
|
|
128
|
+
clearSessionFileCaches();
|
|
129
|
+
invalidateOmpSidebarData();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
found: Boolean(filePath) || rowChanges > 0,
|
|
133
|
+
fileRemoved,
|
|
134
|
+
artifactsRemoved,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Where an unmatched raw turn lands.
|
|
6
|
+
*
|
|
7
|
+
* omp answers a builtin on the command path and writes NO session entry for it,
|
|
8
|
+
* so `/usage` or `/compact` exists only in the chamber's own copy — no JSONL
|
|
9
|
+
* user turn relates to it, and it cannot be matched into place. It was
|
|
10
|
+
* positioned by scanning the stored array forward for the next row the JSONL
|
|
11
|
+
* carried, which only worked because the overlay also held the mirrored rows —
|
|
12
|
+
* the very rows `overlayRowsForOmpSession` drops. So this is the regression the
|
|
13
|
+
* narrowing introduced: those turns fell to the END of the timeline, after
|
|
14
|
+
* answers that came later. The clock fixes it, and the two tests below are the
|
|
15
|
+
* behaviour and the equivalence.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
|
|
20
|
+
import { mergeOmpAttachments, overlayRowsForOmpSession, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
|
|
21
|
+
|
|
22
|
+
/** Arbitrary epoch ms, one second per step, so the order is exact. */
|
|
23
|
+
const at = (second: number) => 1_790_000_000_000 + second * 1000;
|
|
24
|
+
|
|
25
|
+
/** The conversation omp recorded. `/usage` is absent: a builtin writes nothing. */
|
|
26
|
+
function jsonl(): StoredMessage[] {
|
|
27
|
+
return [
|
|
28
|
+
{ id: 'omp-u1', role: 'user', content: 'first question', startedAt: at(0) },
|
|
29
|
+
{ id: 'omp-a1', role: 'ai', content: 'first answer', startedAt: at(1) },
|
|
30
|
+
{ id: 'omp-u2', role: 'user', content: 'second question', startedAt: at(30) },
|
|
31
|
+
{ id: 'omp-a2', role: 'ai', content: 'second answer', startedAt: at(31) },
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The chamber's copy of the same timeline, built in the order it was lived:
|
|
36
|
+
* mirrors and local rows interleaved by time. */
|
|
37
|
+
function storedFull(): StoredMessage[] {
|
|
38
|
+
return [
|
|
39
|
+
{ id: 'msg-1-user', role: 'user', content: 'first question', startedAt: at(0) },
|
|
40
|
+
{ id: 'omp-a1', role: 'ai', content: 'first answer', startedAt: at(1) },
|
|
41
|
+
{ id: 'msg-2-user', role: 'user', content: '/usage', startedAt: at(2) },
|
|
42
|
+
{ id: 'cmdout-1', role: 'ai', content: '', notice: '/usage output', startedAt: at(3) },
|
|
43
|
+
{ id: 'msg-3-user', role: 'user', content: 'second question', startedAt: at(30) },
|
|
44
|
+
{ id: 'omp-a2', role: 'ai', content: 'second answer', startedAt: at(31) },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const order = (messages: StoredMessage[]) => messages.map((m) => m.notice ?? m.content);
|
|
49
|
+
|
|
50
|
+
describe('unmatched raw turn placement', () => {
|
|
51
|
+
test('lands where the user sent it, not after the turns that followed', () => {
|
|
52
|
+
const merged = mergeOmpAttachments(jsonl(), JSON.stringify(overlayRowsForOmpSession(storedFull())));
|
|
53
|
+
|
|
54
|
+
expect(order(merged)).toEqual([
|
|
55
|
+
'first question',
|
|
56
|
+
'first answer',
|
|
57
|
+
'/usage',
|
|
58
|
+
'/usage output',
|
|
59
|
+
'second question',
|
|
60
|
+
'second answer',
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('narrowing the overlay does not move it', () => {
|
|
65
|
+
const full = mergeOmpAttachments(jsonl(), JSON.stringify(storedFull()));
|
|
66
|
+
const narrowed = mergeOmpAttachments(
|
|
67
|
+
jsonl(),
|
|
68
|
+
JSON.stringify(overlayRowsForOmpSession(storedFull())),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(narrowed).toEqual(full);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a turn with no clock to read still renders rather than being dropped', () => {
|
|
75
|
+
const clockless: StoredMessage[] = [
|
|
76
|
+
{ id: 'msg-1-user', role: 'user', content: 'first question', startedAt: at(0) },
|
|
77
|
+
{ id: 'msg-2-user', role: 'user', content: '/usage' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const merged = mergeOmpAttachments(jsonl(), JSON.stringify(clockless));
|
|
81
|
+
|
|
82
|
+
expect(order(merged)).toContain('/usage');
|
|
83
|
+
expect(merged.some((m) => m.startedAt === at(0))).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* The overlay narrowing. For a session omp owns on disk the transcript lives in
|
|
6
|
+
* the JSONL, so the chamber's copy only has to carry what omp cannot record
|
|
7
|
+
* there — the user turns (raw composer text, attachment metadata) and the notice
|
|
8
|
+
* rows. Everything else was a mirror written on every `message_end`.
|
|
9
|
+
*
|
|
10
|
+
* The load-bearing property is the EQUIVALENCE below: narrowing the stored copy
|
|
11
|
+
* must not change a single rendered row. `mergeOmpAttachments` is the only
|
|
12
|
+
* reader, and it reads user rows and notices only — if that ever stops being
|
|
13
|
+
* true, this test fails rather than the timeline silently losing turns.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
|
|
18
|
+
import { mergeOmpAttachments, overlayRowsForOmpSession, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
|
|
19
|
+
|
|
20
|
+
const NOTICE = 'Context window: 1000000 tokens (1% used)';
|
|
21
|
+
|
|
22
|
+
/** The conversation as omp wrote it: the `@agent` turn was delivered as a task
|
|
23
|
+
* delegation prompt, and the builtin command wrote no entry at all. */
|
|
24
|
+
function jsonl(): StoredMessage[] {
|
|
25
|
+
return [
|
|
26
|
+
{ id: 'omp-u1', role: 'user', content: 'hello' },
|
|
27
|
+
{ id: 'omp-a1', role: 'ai', content: 'hi there' },
|
|
28
|
+
{ id: 'omp-u2', role: 'user', content: 'Use the task tool to delegate this request: @agent' },
|
|
29
|
+
{ id: 'omp-a2', role: 'ai', content: 'delegated' },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The same conversation as the chamber mirrored it: its own id on the turns the
|
|
34
|
+
* user typed, the attachment metadata the JSONL cannot carry, and the command's
|
|
35
|
+
* notice row — which is the only place its output exists at all. */
|
|
36
|
+
function storedFull(): StoredMessage[] {
|
|
37
|
+
return [
|
|
38
|
+
{ id: 'msg-1-user', role: 'user', content: 'hello' },
|
|
39
|
+
{ id: 'omp-a1', role: 'ai', content: 'hi there' },
|
|
40
|
+
{
|
|
41
|
+
id: 'msg-2-user',
|
|
42
|
+
role: 'user',
|
|
43
|
+
content: '@agent',
|
|
44
|
+
attachments: [{ name: 'notes.md', content: '# notes' }],
|
|
45
|
+
},
|
|
46
|
+
{ id: 'omp-a2', role: 'ai', content: 'delegated' },
|
|
47
|
+
{ id: 'cmdout-1', role: 'ai', content: '', notice: NOTICE },
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('overlayRowsForOmpSession', () => {
|
|
52
|
+
test('keeps the user turns and the notice rows, in their original order', () => {
|
|
53
|
+
const kept = overlayRowsForOmpSession(storedFull());
|
|
54
|
+
|
|
55
|
+
expect(kept.map((m) => m.id)).toEqual(['msg-1-user', 'msg-2-user', 'cmdout-1']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('keeps the attachment metadata the JSONL cannot carry', () => {
|
|
59
|
+
const kept = overlayRowsForOmpSession(storedFull());
|
|
60
|
+
|
|
61
|
+
expect(kept[1].attachments).toEqual([{ name: 'notes.md', content: '# notes' }]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('drops every role the merge never reads, including assistant rows', () => {
|
|
65
|
+
const rows: StoredMessage[] = [
|
|
66
|
+
{ id: 'u', role: 'user', content: 'ask' },
|
|
67
|
+
{ id: 'a', role: 'ai', content: 'answer' },
|
|
68
|
+
{ id: 'as', role: 'assistant', content: 'answer' },
|
|
69
|
+
{ id: 's', role: 'system', content: 'You are helpful.' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
expect(overlayRowsForOmpSession(rows).map((m) => m.id)).toEqual(['u']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('drops a notice with no text and entries that are not objects', () => {
|
|
76
|
+
const rows = [
|
|
77
|
+
null,
|
|
78
|
+
'a bare string',
|
|
79
|
+
42,
|
|
80
|
+
{ id: 'blank', role: 'ai', content: '', notice: ' \n ' },
|
|
81
|
+
{ id: 'kept', role: 'ai', content: '', notice: NOTICE },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
expect(overlayRowsForOmpSession(rows as StoredMessage[]).map((m) => m.id)).toEqual(['kept']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('narrows the stored copy without changing a single rendered row', () => {
|
|
88
|
+
const full = mergeOmpAttachments(jsonl(), JSON.stringify(storedFull()));
|
|
89
|
+
const narrowed = mergeOmpAttachments(
|
|
90
|
+
jsonl(),
|
|
91
|
+
JSON.stringify(overlayRowsForOmpSession(storedFull())),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(narrowed).toEqual(full);
|
|
95
|
+
// The rows only the overlay could supply are still there: the turn as the
|
|
96
|
+
// user typed it (`@agent`, not the delegation prompt omp delivered) and the
|
|
97
|
+
// command notice, which the session file never recorded.
|
|
98
|
+
expect(narrowed.map((m) => m.notice ?? m.content)).toEqual([
|
|
99
|
+
'hello',
|
|
100
|
+
'hi there',
|
|
101
|
+
'@agent',
|
|
102
|
+
NOTICE,
|
|
103
|
+
'delegated',
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -41,6 +41,14 @@ export interface StoredMessage {
|
|
|
41
41
|
content: string;
|
|
42
42
|
date?: string;
|
|
43
43
|
timestamp?: string;
|
|
44
|
+
/**
|
|
45
|
+
* When omp recorded the entry, in epoch ms — `toChatMessage` stamps every
|
|
46
|
+
* JSONL-derived row with it, and the chamber's own rows carry the clock they
|
|
47
|
+
* were created at. It is what positions a raw turn that no JSONL turn relates
|
|
48
|
+
* to (see `anchorIndexFor`); `date` cannot serve, because a chamber row's is a
|
|
49
|
+
* display string ("Today, 03:57 PM") rather than an ISO instant.
|
|
50
|
+
*/
|
|
51
|
+
startedAt?: number;
|
|
44
52
|
attachments?: StoredAttachment[];
|
|
45
53
|
/**
|
|
46
54
|
* A notice row — omp's own reminder/task-result entries, or the chamber's
|
|
@@ -52,6 +60,39 @@ export interface StoredMessage {
|
|
|
52
60
|
notice?: string;
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The only rows a chamber overlay needs for a session whose transcript omp owns
|
|
65
|
+
* on disk: the user turns (the raw composer text and the attachment metadata the
|
|
66
|
+
* JSONL cannot record) and the notice rows (builtin command output, which omp
|
|
67
|
+
* writes nowhere).
|
|
68
|
+
*
|
|
69
|
+
* Every other row is a mirror of a JSONL entry: this module never reads one, and
|
|
70
|
+
* rewriting the transcript on every `message_end` is what grew the overlay to
|
|
71
|
+
* the size of the conversation it copies — measured at 165.6 MB of 167.9 MB of
|
|
72
|
+
* `ai` rows that nothing consumes. A session with NO session file is a different
|
|
73
|
+
* case and must stay whole — there the DB is the only copy — so callers gate on
|
|
74
|
+
* the file, never on the id's shape.
|
|
75
|
+
*
|
|
76
|
+
* The merge depends on no mirror to place a turn either: a stored raw turn that
|
|
77
|
+
* no JSONL user turn relates to is positioned by its own clock
|
|
78
|
+
* (`anchorIndexFor`), not by scanning the stored array for a row the JSONL still
|
|
79
|
+
* carries — which is what makes this narrowing self-sufficient rather than a
|
|
80
|
+
* change that quietly moves a `/usage` or `/compact` bubble to the end of the
|
|
81
|
+
* timeline. Verified against this install's own files: of 260 stored sessions
|
|
82
|
+
* compared against their JSONLs, only the turns whose anchor the scan had been
|
|
83
|
+
* supplying change position, and they change to the time they were sent.
|
|
84
|
+
*/
|
|
85
|
+
export function overlayRowsForOmpSession<T>(messages: T[]): T[] {
|
|
86
|
+
return messages.filter((message) => {
|
|
87
|
+
if (typeof message !== 'object' || message === null) return false;
|
|
88
|
+
const record = message as { role?: unknown; notice?: unknown };
|
|
89
|
+
if (record.role === 'user') return true;
|
|
90
|
+
// Whitespace-only notices are dropped by the merge anyway; keeping one would
|
|
91
|
+
// only carry bytes no renderer ever shows.
|
|
92
|
+
return typeof record.notice === 'string' && record.notice.trim().length > 0;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
function isRawComposerInput(content: string): boolean {
|
|
56
97
|
return /^\s*\//.test(content) || /(^|\s)@[A-Za-z0-9_-]+/.test(content);
|
|
57
98
|
}
|
|
@@ -100,6 +141,40 @@ function mergeAttachmentLists(
|
|
|
100
141
|
return [...merged, ...remaining];
|
|
101
142
|
}
|
|
102
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Where an unmatched raw turn belongs in the merged timeline.
|
|
146
|
+
*
|
|
147
|
+
* Its own clock decides: the turn was typed before the next row omp recorded
|
|
148
|
+
* after it, so it is inserted ahead of the first merged row that carries a later
|
|
149
|
+
* `startedAt`. This replaced a scan for "the next stored row the JSONL still
|
|
150
|
+
* carries" — that scan worked only while the overlay also held the mirrored
|
|
151
|
+
* rows, which are exactly the rows `overlayRowsForOmpSession` drops, so it sent
|
|
152
|
+
* every such turn to the tail of the timeline of a narrowed session (measured:
|
|
153
|
+
* a `/usage` or `/compact` turn, 7 of them in one install).
|
|
154
|
+
*
|
|
155
|
+
* The scan is kept for a row with no clock to read — a legacy stored turn —
|
|
156
|
+
* where it still resolves against an un-narrowed overlay, and `merged.length`
|
|
157
|
+
* (append at the end) is the last resort rather than a silent drop.
|
|
158
|
+
*/
|
|
159
|
+
function anchorIndexFor(
|
|
160
|
+
raw: StoredMessage,
|
|
161
|
+
merged: StoredMessage[],
|
|
162
|
+
jsonlIndex: Map<string, number>,
|
|
163
|
+
stored: StoredMessage[],
|
|
164
|
+
): number {
|
|
165
|
+
const at = raw.startedAt;
|
|
166
|
+
if (typeof at === 'number' && Number.isFinite(at)) {
|
|
167
|
+
const after = merged.findIndex((m) => typeof m.startedAt === 'number' && m.startedAt > at);
|
|
168
|
+
if (after !== -1) return after;
|
|
169
|
+
}
|
|
170
|
+
for (let i = stored.indexOf(raw) + 1; i < stored.length; i += 1) {
|
|
171
|
+
const id = stored[i]?.id;
|
|
172
|
+
const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
|
|
173
|
+
if (found !== undefined) return found;
|
|
174
|
+
}
|
|
175
|
+
return merged.length;
|
|
176
|
+
}
|
|
177
|
+
|
|
103
178
|
/** Merge user turns and attachments from the chamber DB copy onto the
|
|
104
179
|
* JSONL-loaded messages. */
|
|
105
180
|
export function mergeOmpAttachments(
|
|
@@ -136,21 +211,13 @@ export function mergeOmpAttachments(
|
|
|
136
211
|
return attachments.length ? { ...next, attachments } : next;
|
|
137
212
|
});
|
|
138
213
|
|
|
139
|
-
// Unmatched raw turns are
|
|
140
|
-
//
|
|
214
|
+
// Unmatched raw turns are positioned by their own clock, and spliced
|
|
215
|
+
// highest-anchor-first so earlier indices stay valid.
|
|
141
216
|
const insertions = new Map<number, StoredMessage[]>();
|
|
142
217
|
storedUsers.forEach((storedMsg, userIndex) => {
|
|
143
218
|
if (used.has(userIndex) || !isRawComposerInput(storedMsg.content)) return;
|
|
144
219
|
if (storedMsg.id && jsonlIndex.has(storedMsg.id)) return;
|
|
145
|
-
|
|
146
|
-
for (let i = stored.indexOf(storedMsg) + 1; i < stored.length; i += 1) {
|
|
147
|
-
const id = stored[i]?.id;
|
|
148
|
-
const found = typeof id === 'string' ? jsonlIndex.get(id) : undefined;
|
|
149
|
-
if (found !== undefined) {
|
|
150
|
-
anchor = found;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
220
|
+
const anchor = anchorIndexFor(storedMsg, merged, jsonlIndex, stored);
|
|
154
221
|
const group = insertions.get(anchor);
|
|
155
222
|
if (group) group.push(storedMsg);
|
|
156
223
|
else insertions.set(anchor, [storedMsg]);
|
|
@@ -60,6 +60,19 @@ export async function loadOmpSidebarData(): Promise<OmpSidebarData> {
|
|
|
60
60
|
return slot.inFlight;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Drop the cached dataset so the next load re-scans disk. For a mutation the
|
|
65
|
+
* TTL cannot see: deleting a session removes a row rather than changing one, so
|
|
66
|
+
* nothing in the cached snapshot is stale — it is simply wrong, and the refresh
|
|
67
|
+
* that follows the delete would otherwise serve it back for up to the TTL.
|
|
68
|
+
*/
|
|
69
|
+
export function invalidateOmpSidebarData(): void {
|
|
70
|
+
const slot = globalThis.__ompChamberSidebarDataCache;
|
|
71
|
+
if (!slot) return;
|
|
72
|
+
slot.data = undefined as unknown as OmpSidebarData;
|
|
73
|
+
slot.expiresAt = 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/** Resolve each unique cwd to its project root; bounded concurrency so 100+
|
|
64
77
|
* unique cwds don't spawn 100 parallel git processes. */
|
|
65
78
|
async function resolveRootsByCwd(cwds: string[]): Promise<Map<string, string>> {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* omp discovers plugins once per process, so a skill written while a session
|
|
6
|
+
* runs is invisible to it until a reload. `/reload-plugins` is the refresh, and
|
|
7
|
+
* it is only safe to send to an IDLE session: it answers `agentInvoked: false`
|
|
8
|
+
* (no model turn, nothing written to the transcript), but a prompt dispatched
|
|
9
|
+
* into a RUNNING turn is a queue insert with user-visible consequences. The
|
|
10
|
+
* busy guard is therefore an invariant, not an optimization — a broadcast that
|
|
11
|
+
* ignored it would inject a command into every in-flight turn.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterAll, describe, expect, mock, test } from 'bun:test';
|
|
15
|
+
|
|
16
|
+
interface FakeSession {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
isBusy: () => boolean;
|
|
19
|
+
send: (command: unknown) => Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sent: Array<{ sessionId: string; command: unknown }> = [];
|
|
23
|
+
let sessions: FakeSession[] = [];
|
|
24
|
+
let failFor: string | null = null;
|
|
25
|
+
|
|
26
|
+
mock.module('@/server/lib/omp/rpc/session-registry', () => ({
|
|
27
|
+
listRpcSessions: () => sessions,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Static import cannot work here: `mock.module` only intercepts modules
|
|
31
|
+
// registered BEFORE the importer is evaluated, so the module under test has to
|
|
32
|
+
// load after the mock is in place.
|
|
33
|
+
const { reloadLiveSessions } = await import('@/server/lib/omp/session/reload.server');
|
|
34
|
+
|
|
35
|
+
function session(sessionId: string, busy: boolean): FakeSession {
|
|
36
|
+
return {
|
|
37
|
+
sessionId,
|
|
38
|
+
isBusy: () => busy,
|
|
39
|
+
async send(command: unknown) {
|
|
40
|
+
if (failFor === sessionId) throw new Error('session gone');
|
|
41
|
+
sent.push({ sessionId, command });
|
|
42
|
+
return { agentInvoked: false };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('reloadLiveSessions', () => {
|
|
48
|
+
test('sends /reload-plugins to idle sessions only', async () => {
|
|
49
|
+
sent.length = 0;
|
|
50
|
+
failFor = null;
|
|
51
|
+
sessions = [session('idle-1', false), session('busy-1', true), session('idle-2', false)];
|
|
52
|
+
|
|
53
|
+
const reloaded = await reloadLiveSessions();
|
|
54
|
+
|
|
55
|
+
expect(reloaded.sort()).toEqual(['idle-1', 'idle-2']);
|
|
56
|
+
expect(sent.map((entry) => entry.sessionId).sort()).toEqual(['idle-1', 'idle-2']);
|
|
57
|
+
// The prompt form is the point: no RPC command exists for a reload.
|
|
58
|
+
expect(sent.every((entry) => JSON.stringify(entry.command) === JSON.stringify({ type: 'prompt', message: '/reload-plugins' }))).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a failing session does not stop the others', async () => {
|
|
62
|
+
sent.length = 0;
|
|
63
|
+
failFor = 'idle-1';
|
|
64
|
+
sessions = [session('idle-1', false), session('idle-2', false)];
|
|
65
|
+
|
|
66
|
+
const reloaded = await reloadLiveSessions();
|
|
67
|
+
|
|
68
|
+
expect(reloaded).toEqual(['idle-2']);
|
|
69
|
+
expect(sent.map((entry) => entry.sessionId)).toEqual(['idle-2']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('no live sessions is a no-op', async () => {
|
|
73
|
+
sent.length = 0;
|
|
74
|
+
failFor = null;
|
|
75
|
+
sessions = [];
|
|
76
|
+
|
|
77
|
+
expect(await reloadLiveSessions()).toEqual([]);
|
|
78
|
+
expect(sent.length).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
mock.restore();
|
|
84
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Refresh the plugin/skill inventory of the LIVE omp children.
|
|
8
|
+
*
|
|
9
|
+
* omp discovers skills, commands and plugins once per process and does not
|
|
10
|
+
* watch the filesystem: a SKILL.md written while a session runs is invisible to
|
|
11
|
+
* that session until it restarts (measured: `get_available_commands` kept
|
|
12
|
+
* reporting 8 skill entries after a ninth landed on disk, and `/skill:<new>`
|
|
13
|
+
* resolved to nothing). The only refresh omp exposes is the `/reload-plugins`
|
|
14
|
+
* builtin, which has a text-mode `handle` — so it is reachable as a prompt.
|
|
15
|
+
*
|
|
16
|
+
* Verified against omp 18.3.5: sending `/reload-plugins` as a prompt answers
|
|
17
|
+
* `agentInvoked: false` (no model turn), writes nothing to the transcript, and
|
|
18
|
+
* emits `available_commands_update`, after which the new skill is listed and
|
|
19
|
+
* runnable. That is what makes broadcasting it safe.
|
|
20
|
+
*
|
|
21
|
+
* Only IDLE sessions are reloaded. A busy session is mid-turn, and a prompt
|
|
22
|
+
* dispatched into a running turn is a queue insert with user-visible
|
|
23
|
+
* consequences; its next turn picks the new skills up from the restart path
|
|
24
|
+
* instead. Best-effort: one session failing never blocks the others.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { listRpcSessions } from '@/server/lib/omp/rpc/session-registry';
|
|
28
|
+
|
|
29
|
+
/** Ask every idle live session to re-discover plugins. Returns the ids that
|
|
30
|
+
* accepted the reload. */
|
|
31
|
+
export async function reloadLiveSessions(): Promise<string[]> {
|
|
32
|
+
const reloaded: string[] = [];
|
|
33
|
+
await Promise.all(
|
|
34
|
+
listRpcSessions().map(async (session) => {
|
|
35
|
+
if (session.isBusy()) return;
|
|
36
|
+
try {
|
|
37
|
+
await session.send({ type: 'prompt', message: '/reload-plugins' });
|
|
38
|
+
reloaded.push(session.sessionId);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(
|
|
41
|
+
`Failed to reload plugins for session ${session.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
return reloaded;
|
|
47
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* parse it exists to avoid.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { promises as fs } from 'fs';
|
|
24
|
+
import { promises as fs, statSync } from 'fs';
|
|
25
25
|
import { extractSubagentHistory } from '@/server/lib/omp/subagent/history';
|
|
26
26
|
import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths';
|
|
27
27
|
|
|
@@ -35,9 +35,33 @@ const MAX_ENTRIES = 2_000;
|
|
|
35
35
|
|
|
36
36
|
const cache = new Map<string, PresenceEntry>();
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Version of the sibling artifacts directory, or `0` when it does not exist —
|
|
40
|
+
* which is the COMMON case, since most sessions never spawn a subagent.
|
|
41
|
+
*
|
|
42
|
+
* The stat is SYNC (`statSync` + `throwIfNoEntry: false`) rather than an
|
|
43
|
+
* awaited `fs.stat`, because an awaited one rejects on that common path and a
|
|
44
|
+
* caught rejection from `fs` is still reported by Bun's rejection tracker when
|
|
45
|
+
* this module is re-evaluated by `bun run --hot` — the dev server, and any
|
|
46
|
+
* `ompchamber serve` whose NODE_ENV is unset. The result was a full ENOENT
|
|
47
|
+
* report with a `Bun v1.4.2` footer printed over a healthy server's own sidebar
|
|
48
|
+
* polls. Measured on Bun 1.4.2 against this exact function — 40 concurrent
|
|
49
|
+
* probes per request, 40 module rewrites, 6 runs: `try { await fs.stat(dir) }
|
|
50
|
+
* catch {}` reported ENOENT 8-12 times, `.catch()` 4-8 times, while both
|
|
51
|
+
* `throwIfNoEntry: false` and `statSync` reported zero, all yielding the same
|
|
52
|
+
* `0` and the same `hasSubagents: false`. The distinguishing property is not
|
|
53
|
+
* the error handling but whether a rejected promise is created at all; a sync
|
|
54
|
+
* throw is caught on the spot and never reaches the tracker.
|
|
55
|
+
*
|
|
56
|
+
* Cost is not a concern: 343 sessions (this machine's sidebar) cost 0.37 ms per
|
|
57
|
+
* poll versus 0.32 ms for the async form — 0.055 ms, on a poll that runs at
|
|
58
|
+
* most every few seconds.
|
|
59
|
+
*/
|
|
60
|
+
function siblingDirVersion(dir: string): number {
|
|
39
61
|
try {
|
|
40
|
-
|
|
62
|
+
// Only ENOENT is suppressed; EACCES and the like still throw and are
|
|
63
|
+
// swallowed here, leaving the `0` contract intact.
|
|
64
|
+
return statSync(dir, { throwIfNoEntry: false })?.mtimeMs ?? 0;
|
|
41
65
|
} catch {
|
|
42
66
|
return 0;
|
|
43
67
|
}
|
|
@@ -48,17 +72,24 @@ export async function sessionHasSubagents(
|
|
|
48
72
|
sessionLastEntryAt: string,
|
|
49
73
|
): Promise<boolean> {
|
|
50
74
|
const siblingDir = siblingDirForSession(sessionPath);
|
|
51
|
-
const
|
|
75
|
+
const siblingMtime = siblingDirVersion(siblingDir);
|
|
76
|
+
const version = `${sessionLastEntryAt}:${siblingMtime}`;
|
|
52
77
|
|
|
53
78
|
const cached = cache.get(sessionPath);
|
|
54
79
|
if (cached && cached.version === version) return cached.hasSubagents;
|
|
55
80
|
|
|
56
81
|
let hasSubagents = false;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
// Guarded on the version: an unguarded readdir on the absent dir is the same
|
|
83
|
+
// spurious-rejection leak, and it rejects on the same sessions. The try/catch
|
|
84
|
+
// is what remains for a genuine read failure (the dir removed between the
|
|
85
|
+
// stat and here), which is rare enough not to be the reported noise.
|
|
86
|
+
if (siblingMtime !== 0) {
|
|
87
|
+
try {
|
|
88
|
+
const files = await fs.readdir(siblingDir);
|
|
89
|
+
hasSubagents = files.some((file) => file.endsWith('.jsonl'));
|
|
90
|
+
} catch {
|
|
91
|
+
hasSubagents = false;
|
|
92
|
+
}
|
|
62
93
|
}
|
|
63
94
|
if (!hasSubagents) {
|
|
64
95
|
try {
|