omp-onlyne 0.0.0-stage → 0.2.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/src/store.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import type { Entry, Storage, ThreadRef, Transaction } from "@onlyne/lib";
5
+ import type { ContextRecord } from "./types.ts";
6
+ import { bridgeText as t } from "./bridge-text.ts";
7
+
8
+ /** Key/value rows under one SQLite file; the library's durable state and the plugin's registry share it. */
9
+ export class SqliteStorage implements Storage {
10
+ readonly guarantee = "durable" as const;
11
+ readonly #db: Database;
12
+ #tail: Promise<unknown> = Promise.resolve();
13
+ #closed = false;
14
+
15
+ constructor(path: string) {
16
+ // The store carries trust rows and, while a rotation is unacknowledged, the old private
17
+ // key that proves it. It is secret material: 0700 for the directory, 0600 for the files,
18
+ // including the WAL and shared-memory sidecars SQLite creates beside them.
19
+ const dir = dirname(path);
20
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ chmodSync(dir, 0o700);
22
+ this.#db = new Database(path, { create: true });
23
+ this.#db.exec("PRAGMA journal_mode = WAL");
24
+ this.#db.exec("PRAGMA synchronous = FULL");
25
+ this.#db.exec("PRAGMA busy_timeout = 5000");
26
+ this.#db.exec("CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
27
+ for (const file of [path, `${path}-wal`, `${path}-shm`]) {
28
+ if (existsSync(file)) chmodSync(file, 0o600);
29
+ }
30
+ }
31
+
32
+ /** Serialize every transaction; SQLite's own BEGIN IMMEDIATE would otherwise race across awaits. */
33
+ transaction<T>(work: (tx: Transaction) => Promise<T>): Promise<T> {
34
+ const run = () => this.#execute(work);
35
+ const result = this.#tail.then(run, run);
36
+ this.#tail = result.then(
37
+ () => undefined,
38
+ () => undefined,
39
+ );
40
+ return result;
41
+ }
42
+
43
+ async #execute<T>(work: (tx: Transaction) => Promise<T>): Promise<T> {
44
+ if (this.#closed) throw new Error(t("storeClosed"));
45
+ this.#db.exec("BEGIN IMMEDIATE");
46
+ const tx: Transaction = {
47
+ get: async <V>(key: string) => this.#get<V>(key),
48
+ put: async <V>(key: string, value: V) => this.#put(key, value),
49
+ delete: async (key: string) => {
50
+ this.#db.query("DELETE FROM kv WHERE key = ?").run(key);
51
+ },
52
+ scan: async <V>(prefix: string) => this.#scan<V>(prefix),
53
+ };
54
+ try {
55
+ const value = await work(tx);
56
+ this.#db.exec("COMMIT");
57
+ return value;
58
+ } catch (error) {
59
+ try {
60
+ this.#db.exec("ROLLBACK");
61
+ } catch {
62
+ // The transaction may already be gone; preserve the original failure.
63
+ }
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ #get<T>(key: string): T | undefined {
69
+ const row = this.#db.query<{ value: string }, [string]>("SELECT value FROM kv WHERE key = ?").get(key);
70
+ return row === null ? undefined : (JSON.parse(row.value) as T);
71
+ }
72
+
73
+ #put(key: string, value: unknown): void {
74
+ this.#db
75
+ .query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
76
+ .run(key, JSON.stringify(value));
77
+ }
78
+
79
+ #scan<T>(prefix: string): Array<{ key: string; value: T }> {
80
+ const rows = this.#db
81
+ .query<{ key: string; value: string }, [number, string]>(
82
+ "SELECT key, value FROM kv WHERE substr(key, 1, ?) = ? ORDER BY key",
83
+ )
84
+ .all(prefix.length, prefix);
85
+ return rows.map(row => ({ key: row.key, value: JSON.parse(row.value) as T }));
86
+ }
87
+
88
+ close(): void {
89
+ if (this.#closed) return;
90
+ this.#closed = true;
91
+ this.#db.close();
92
+ }
93
+ }
94
+
95
+ const COUNTER_KEY = "meta/context-counter";
96
+ const CONTEXT_PREFIX = "context/";
97
+
98
+ /**
99
+ * Whether a registered context can answer a delivery. Only the session holding this node's
100
+ * own send right can: a subagent context reads and drafts, never sends. Advertising one as a
101
+ * continuation target hands the sender a message nobody there can reply to, and the sender
102
+ * has no way to see that from the alias (caerulea report 4, problem 7).
103
+ */
104
+ export function canAnswer(record: ContextRecord): boolean {
105
+ return record.agent_id !== undefined && record.agent_id === record.main_agent_id;
106
+ }
107
+
108
+ /** Alias allocation and binding for execution contexts; the store is the authority across restarts. */
109
+ export class ContextRegistry {
110
+ constructor(private readonly storage: Storage, private readonly nodeId: string, private readonly mainAgentId: string) {}
111
+
112
+ /** The main OMP session is addressable like a registered execution context. */
113
+ async ensureMain(): Promise<ContextRecord> {
114
+ return this.storage.transaction(async tx => {
115
+ const key = `${CONTEXT_PREFIX}main`;
116
+ const current = await tx.get<ContextRecord>(key);
117
+ if (current !== undefined) return current;
118
+ const record: ContextRecord = {
119
+ alias: "main", node_id: this.nodeId, main_agent_id: this.mainAgentId, agent_id: this.mainAgentId,
120
+ summary: t("mainSummary"), state: "idle", created_at: new Date().toISOString(),
121
+ };
122
+ await tx.put(key, record);
123
+ return record;
124
+ });
125
+ }
126
+
127
+ async allocate(thread_ref: ThreadRef | undefined, summary: string, source?: Entry["message"]): Promise<ContextRecord> {
128
+ return this.storage.transaction(async tx => {
129
+ const counter = (await tx.get<number>(COUNTER_KEY)) ?? 0;
130
+ const next = counter + 1;
131
+ await tx.put(COUNTER_KEY, next);
132
+ const alias = `c-${String(next)}`;
133
+ const record: ContextRecord = {
134
+ alias,
135
+ node_id: this.nodeId,
136
+ main_agent_id: this.mainAgentId,
137
+ ...(source === undefined ? {} : { source: { sender: source.sender, id: source.id } }),
138
+ ...(thread_ref === undefined ? {} : { thread_ref }),
139
+ summary,
140
+ state: "unbound",
141
+ created_at: new Date().toISOString(),
142
+ };
143
+ await tx.put(`${CONTEXT_PREFIX}${alias}`, record);
144
+ return record;
145
+ });
146
+ }
147
+
148
+ async bind(
149
+ alias: string,
150
+ binding: { agent_id: string; session_file?: string; parent_id?: string },
151
+ ): Promise<ContextRecord> {
152
+ return this.storage.transaction(async tx => {
153
+ const key = `${CONTEXT_PREFIX}${alias}`;
154
+ const current = await tx.get<ContextRecord>(key);
155
+ if (current === undefined) throw new Error(t("contextNotRegistered", { alias }));
156
+ const next: ContextRecord = {
157
+ ...current,
158
+ agent_id: binding.agent_id,
159
+ ...(binding.session_file === undefined ? {} : { session_file: binding.session_file }),
160
+ ...(binding.parent_id === undefined ? {} : { parent_id: binding.parent_id }),
161
+ state: current.agent_id === binding.agent_id ? current.state : "idle",
162
+ bound_at: new Date().toISOString(),
163
+ };
164
+ await tx.put(key, next);
165
+ return next;
166
+ });
167
+ }
168
+
169
+ async find(alias: string): Promise<ContextRecord | undefined> {
170
+ return this.storage.transaction(tx => tx.get<ContextRecord>(`${CONTEXT_PREFIX}${alias}`));
171
+ }
172
+
173
+ async list(): Promise<ContextRecord[]> {
174
+ return this.storage.transaction(async tx =>
175
+ (await tx.scan<ContextRecord>(CONTEXT_PREFIX)).map(row => row.value),
176
+ );
177
+ }
178
+
179
+ async updateState(alias: string, state: ContextRecord["state"]): Promise<ContextRecord | undefined> {
180
+ return this.storage.transaction(async tx => {
181
+ const key = `${CONTEXT_PREFIX}${alias}`;
182
+ const current = await tx.get<ContextRecord>(key);
183
+ if (current === undefined) return undefined;
184
+ const next = { ...current, state };
185
+ await tx.put(key, next);
186
+ return next;
187
+ });
188
+ }
189
+ }
package/src/tern.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tern identity for the bootstrap defaults.
3
+ *
4
+ * `TERN_PANE` is the block id the process runs in. `tern ls --json` is the
5
+ * supported query for the session that owns a block; a missing binary, a
6
+ * non-zero exit, malformed JSON or an unknown block all return `undefined`.
7
+ * The caller may fall back to its own default, but this module never guesses a
8
+ * session name from the cwd or from a prefix.
9
+ */
10
+
11
+ interface TernBlock {
12
+ id?: unknown;
13
+ }
14
+
15
+ interface TernTab {
16
+ blocks?: TernBlock[];
17
+ }
18
+
19
+ interface TernSession {
20
+ name?: unknown;
21
+ tabs?: TernTab[];
22
+ }
23
+
24
+ interface TernList {
25
+ sessions?: TernSession[];
26
+ }
27
+
28
+ /** The Tern block this process runs in, when Tern set it. */
29
+ export function detectTernBlock(): string | undefined {
30
+ const block = process.env.TERN_PANE?.trim();
31
+ return block === undefined || block.length === 0 ? undefined : block;
32
+ }
33
+
34
+ /** The session name owning `block` (default: this process's block), `onlyne:` stripped. */
35
+ export function detectTernSession(block: string | undefined = detectTernBlock()): string | undefined {
36
+ if (block === undefined) return undefined;
37
+ let output: string;
38
+ try {
39
+ const result = Bun.spawnSync([process.env.TERN_ACP ?? "tern", "ls", "--json"], {
40
+ stdout: "pipe",
41
+ stderr: "ignore",
42
+ });
43
+ if (result.exitCode !== 0) return undefined;
44
+ output = new TextDecoder().decode(result.stdout);
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ let parsed: TernList;
49
+ try {
50
+ parsed = JSON.parse(output) as TernList;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ const sessions = Array.isArray(parsed.sessions) ? parsed.sessions : [];
55
+ for (const session of sessions) {
56
+ const tabs = Array.isArray(session.tabs) ? session.tabs : [];
57
+ for (const tab of tabs) {
58
+ const blocks = Array.isArray(tab.blocks) ? tab.blocks : [];
59
+ if (!blocks.some(candidate => String(candidate.id) === block)) continue;
60
+ if (typeof session.name !== "string") return undefined;
61
+ const name = session.name.replace(/^onlyne:/, "").trim();
62
+ return name.length === 0 ? undefined : name;
63
+ }
64
+ }
65
+ return undefined;
66
+ }
@@ -0,0 +1,230 @@
1
+ import type { SessionEntry } from "@oh-my-pi/pi-coding-agent";
2
+ import { USER_TODO_EDIT_CUSTOM_TYPE } from "@oh-my-pi/pi-coding-agent/tools/todo";
3
+ import type { TodoState, TodoStateGoal, TodoStateItem, TodoStatePhase, TodoStatus } from "./todo.ts";
4
+
5
+ /**
6
+ * Branch records the structural reader understands. Kept as literals so this
7
+ * module never imports omp-todo-more; the rich adapter checks the installed
8
+ * module against them before it writes anything.
9
+ */
10
+ export const TODO_MORE_ENTRY = "mouriya.omp.todo-more";
11
+ export const TODO_MORE_RESULT_MARK = "todoMore";
12
+
13
+ /** What `session.setTodoPhases` and the native HUD take. */
14
+ export interface HostTodoTask {
15
+ content: string;
16
+ status: TodoStatus;
17
+ blocker?: string;
18
+ details?: string;
19
+ notes?: string[];
20
+ }
21
+
22
+ export interface HostTodoPhase {
23
+ name: string;
24
+ tasks: HostTodoTask[];
25
+ }
26
+
27
+ const TODO_STATUS: Record<TodoStatus, true> = {
28
+ pending: true,
29
+ in_progress: true,
30
+ completed: true,
31
+ abandoned: true,
32
+ blocked: true,
33
+ };
34
+
35
+ export function isTodoStatus(value: unknown): value is TodoStatus {
36
+ return typeof value === "string" && Object.hasOwn(TODO_STATUS, value);
37
+ }
38
+
39
+ interface NativeTask {
40
+ content: string;
41
+ status: TodoStatus;
42
+ blocker?: string;
43
+ details?: string;
44
+ notes?: string[];
45
+ }
46
+
47
+ interface NativePhase {
48
+ name: string;
49
+ tasks: NativeTask[];
50
+ }
51
+
52
+ /**
53
+ * The host's own todo list on a branch (oldest first): the newest native edit,
54
+ * joined onto the nearest earlier rich snapshot by title. Rich snapshots only
55
+ * contribute explanations; statuses, blockers and ordering are native.
56
+ */
57
+ export function readNativeState(entries: readonly SessionEntry[]): TodoState {
58
+ const pending: NativePhase[][] = [];
59
+ for (let index = entries.length - 1; index >= 0; index--) {
60
+ const entry = entries[index];
61
+ if (entry === undefined) continue;
62
+ const rich = richState(entry);
63
+ if (rich !== undefined) return joinNative(rich, pending);
64
+ const native = nativeRecord(entry);
65
+ if (native === undefined) continue;
66
+ pending.push(native);
67
+ // An empty native edit clears the list; older records must not resurrect it.
68
+ if (native.every(phase => phase.tasks.length === 0)) break;
69
+ }
70
+ return joinNative({ goal: null, phases: [] }, pending);
71
+ }
72
+
73
+ /** The titles one native record defines, for pruning Onlyne's origin roster. */
74
+ export function nativeTitles(entry: SessionEntry): Set<string> | undefined {
75
+ const phases = nativeRecord(entry);
76
+ if (phases === undefined) return undefined;
77
+ const titles = new Set<string>();
78
+ for (const phase of phases) {
79
+ for (const task of phase.tasks) titles.add(`${phase.name}\u0000${task.content}`);
80
+ }
81
+ return titles;
82
+ }
83
+
84
+ /** The host projection of a state: what `setTodoPhases` persists. */
85
+ export function nativePhases(state: TodoState): HostTodoPhase[] {
86
+ return state.phases.map(phase => ({
87
+ name: phase.name,
88
+ tasks: phase.items.map(item => ({
89
+ content: item.title,
90
+ status: item.status,
91
+ ...(item.blocker === undefined ? {} : { blocker: item.blocker }),
92
+ ...(item.details === undefined ? {} : { details: item.details }),
93
+ ...(item.notes === undefined ? {} : { notes: item.notes }),
94
+ })),
95
+ }));
96
+ }
97
+
98
+ function joinNative(base: TodoState, pendingNewestFirst: NativePhase[][]): TodoState {
99
+ return pendingNewestFirst.reduceRight((state, phases) => {
100
+ const known = new Map<string, TodoStateItem>();
101
+ for (const phase of state.phases) {
102
+ for (const item of phase.items) known.set(item.title, item);
103
+ }
104
+ const joined: TodoStatePhase[] = phases.map(phase => ({
105
+ name: phase.name,
106
+ items: phase.tasks.map(task => {
107
+ const previous = known.get(task.content);
108
+ const details = task.details ?? previous?.details;
109
+ const notes = task.notes ?? previous?.notes;
110
+ return {
111
+ title: task.content,
112
+ status: task.status,
113
+ ...(previous?.explanation === undefined ? {} : { explanation: previous.explanation }),
114
+ ...(task.blocker === undefined ? {} : { blocker: task.blocker }),
115
+ ...(details === undefined ? {} : { details }),
116
+ ...(notes === undefined ? {} : { notes }),
117
+ };
118
+ }),
119
+ }));
120
+ let count = 0;
121
+ for (const phase of joined) count += phase.items.length;
122
+ return count === 0 ? { goal: null, phases: [] } : { goal: state.goal, phases: joined };
123
+ }, base);
124
+ }
125
+
126
+ function richState(entry: SessionEntry): TodoState | undefined {
127
+ if (entry.type !== "custom" || entry.customType !== TODO_MORE_ENTRY) return undefined;
128
+ const phases = recordField(entry.data, "phases");
129
+ if (!Array.isArray(phases)) return undefined;
130
+ const parsed: TodoStatePhase[] = [];
131
+ for (const phase of phases) {
132
+ const name = stringField(phase, "name");
133
+ const items = recordField(phase, "items");
134
+ if (name === undefined || !Array.isArray(items)) return undefined;
135
+ const parsedItems: TodoStateItem[] = [];
136
+ for (const item of items) {
137
+ const title = stringField(item, "title");
138
+ const status = recordField(item, "status");
139
+ if (title === undefined || !isTodoStatus(status)) return undefined;
140
+ const explanation = stringField(item, "explanation");
141
+ const blocker = stringField(item, "blocker");
142
+ parsedItems.push({
143
+ title,
144
+ status,
145
+ ...(explanation === undefined ? {} : { explanation }),
146
+ ...(blocker === undefined ? {} : { blocker }),
147
+ });
148
+ }
149
+ parsed.push({ name, items: parsedItems });
150
+ }
151
+ const goal = richGoal(recordField(entry.data, "goal"));
152
+ if (goal === undefined) return undefined;
153
+ let count = 0;
154
+ for (const phase of parsed) count += phase.items.length;
155
+ return count === 0 ? { goal: null, phases: [] } : { goal, phases: parsed };
156
+ }
157
+
158
+ /** `null` means the snapshot carries no goal; `undefined` means it is malformed. */
159
+ function richGoal(value: unknown): TodoStateGoal | null | undefined {
160
+ if (value === null || value === undefined) return null;
161
+ const objective = stringField(value, "objective");
162
+ const measure = stringField(value, "measure");
163
+ const doneWhen = stringField(value, "doneWhen");
164
+ if (objective === undefined || measure === undefined || doneWhen === undefined) return undefined;
165
+ if (objective.trim().length === 0 || measure.trim().length === 0 || doneWhen.trim().length === 0) return undefined;
166
+ return { objective, measure, doneWhen };
167
+ }
168
+
169
+ function nativeRecord(entry: SessionEntry): NativePhase[] | undefined {
170
+ if (entry.type === "custom") {
171
+ if (entry.customType !== USER_TODO_EDIT_CUSTOM_TYPE) return undefined;
172
+ return parseNativePhases(recordField(entry.data, "phases"));
173
+ }
174
+ if (entry.type !== "message") return undefined;
175
+ const message = entry.message as unknown as {
176
+ role?: unknown;
177
+ toolName?: unknown;
178
+ isError?: unknown;
179
+ details?: { op?: unknown; phases?: unknown; [TODO_MORE_RESULT_MARK]?: unknown };
180
+ };
181
+ if (message.role !== "toolResult" || message.toolName !== "todo" || message.isError === true) return undefined;
182
+ if (message.details?.op === "view") return undefined;
183
+ // todo-more's own results follow the entry that records them.
184
+ if (message.details?.[TODO_MORE_RESULT_MARK] === true) return undefined;
185
+ return parseNativePhases(message.details?.phases);
186
+ }
187
+
188
+ function parseNativePhases(value: unknown): NativePhase[] | undefined {
189
+ if (!Array.isArray(value)) return undefined;
190
+ const phases: NativePhase[] = [];
191
+ for (const phase of value) {
192
+ const name = stringField(phase, "name");
193
+ const tasks = recordField(phase, "tasks");
194
+ if (name === undefined || !Array.isArray(tasks)) return undefined;
195
+ const parsed: NativeTask[] = [];
196
+ for (const task of tasks) {
197
+ const content = stringField(task, "content");
198
+ const status = recordField(task, "status");
199
+ if (content === undefined || !isTodoStatus(status)) return undefined;
200
+ const blocker = stringField(task, "blocker");
201
+ const details = stringField(task, "details");
202
+ const notes = stringListField(task, "notes");
203
+ parsed.push({
204
+ content,
205
+ status,
206
+ ...(blocker === undefined ? {} : { blocker }),
207
+ ...(details === undefined ? {} : { details }),
208
+ ...(notes === undefined ? {} : { notes }),
209
+ });
210
+ }
211
+ phases.push({ name, tasks: parsed });
212
+ }
213
+ return phases;
214
+ }
215
+
216
+ function stringListField(value: unknown, field: string): string[] | undefined {
217
+ const list = recordField(value, field);
218
+ if (!Array.isArray(list)) return undefined;
219
+ return list.every(entry => typeof entry === "string") ? (list as string[]) : undefined;
220
+ }
221
+
222
+ export function recordField(value: unknown, field: string): unknown {
223
+ if (value === null || typeof value !== "object") return undefined;
224
+ return (value as Record<string, unknown>)[field];
225
+ }
226
+
227
+ export function stringField(value: unknown, field: string): string | undefined {
228
+ const text = recordField(value, field);
229
+ return typeof text === "string" ? text : undefined;
230
+ }