@mahmoudwael/opai 0.1.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.
@@ -0,0 +1,183 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { configDir } from './config.js';
5
+ import { accent, bold, danger, good, warning } from './ui.js';
6
+ function dayKey(value) {
7
+ return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
8
+ }
9
+ function validHistory(value) {
10
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+ export class DashboardHistoryStore {
13
+ path;
14
+ writable = true;
15
+ constructor(path = join(configDir, 'dashboard-history.json')) {
16
+ this.path = path;
17
+ }
18
+ async all() {
19
+ try {
20
+ const value = JSON.parse(await readFile(this.path, 'utf8'));
21
+ return validHistory(value) ? value : {};
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ }
27
+ async save(value) {
28
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
29
+ const temp = `${this.path}.${randomUUID()}.tmp`;
30
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
31
+ await rename(temp, this.path);
32
+ }
33
+ async record(provider, tickets, at = new Date()) {
34
+ if (!this.writable)
35
+ return;
36
+ const history = await this.all();
37
+ const snapshot = { date: dayKey(at), refreshedAt: at.getTime(), ticketIds: [...new Set(tickets.map(ticket => ticket.id))], total: tickets.length };
38
+ const previous = history[provider] ?? [];
39
+ const same = previous.find(item => item.date === snapshot.date);
40
+ if (same?.refreshedAt === snapshot.refreshedAt && same.total === snapshot.total && same.ticketIds.join('\0') === snapshot.ticketIds.join('\0'))
41
+ return;
42
+ history[provider] = [...previous.filter(item => item.date !== snapshot.date), snapshot]
43
+ .filter(item => item.refreshedAt >= at.getTime() - 35 * 86_400_000)
44
+ .sort((a, b) => a.refreshedAt - b.refreshedAt);
45
+ try {
46
+ await this.save(history);
47
+ }
48
+ catch {
49
+ this.writable = false;
50
+ }
51
+ }
52
+ async list(provider) { return (await this.all())[provider] ?? []; }
53
+ }
54
+ function activityTimes(session) {
55
+ const values = session.usedAt?.length ? session.usedAt : [session.createdAt, session.lastUsedAt].filter((value) => Boolean(value));
56
+ return [...new Set(values)].map(Date.parse).filter(Number.isFinite);
57
+ }
58
+ export function buildDashboard(tickets, refreshedAt, registry, provider, history, now = Date.now()) {
59
+ const groups = Object.entries(registry).filter(([key, sessions]) => key.startsWith(`${provider}:`) && sessions.length);
60
+ const sessions = groups.flatMap(([key, values]) => values.map(session => ({ key, session })));
61
+ const statuses = [...tickets.reduce((counts, ticket) => counts.set(ticket.status, (counts.get(ticket.status) ?? 0) + 1), new Map())]
62
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
63
+ const start = new Date(now);
64
+ start.setHours(0, 0, 0, 0);
65
+ start.setDate(start.getDate() - 6);
66
+ const days = Array.from({ length: 7 }, (_, offset) => { const date = new Date(start); date.setDate(start.getDate() + offset); return date; });
67
+ const events = sessions.flatMap(item => activityTimes(item.session).map(at => ({ ...item, at })));
68
+ const activity = days.map(date => ({ label: new Intl.DateTimeFormat(undefined, { weekday: 'narrow' }).format(date), count: events.filter(event => dayKey(new Date(event.at)) === dayKey(date)).length }));
69
+ const touched = new Set(events.filter(event => event.at >= start.getTime() && event.at <= now).map(event => event.key));
70
+ const recentEvent = [...events].filter(event => event.at <= now).sort((a, b) => b.at - a.at)[0];
71
+ const recentTicket = recentEvent?.session.ticket;
72
+ const snapshots = history.filter(item => item.refreshedAt >= start.getTime() && item.refreshedAt <= now).sort((a, b) => a.refreshedAt - b.refreshedAt);
73
+ const cleared = new Set();
74
+ for (let index = 1; index < snapshots.length; index++) {
75
+ const current = new Set(snapshots[index].ticketIds);
76
+ for (const id of snapshots[index - 1].ticketIds)
77
+ if (!current.has(id))
78
+ cleared.add(id);
79
+ }
80
+ return {
81
+ open: tickets.length,
82
+ bugs: tickets.filter(ticket => ticket.type === 'Bug').length,
83
+ stories: tickets.filter(ticket => ticket.type === 'User Story').length,
84
+ sessions: sessions.length,
85
+ resumable: groups.length,
86
+ refreshedAt,
87
+ statuses,
88
+ agents: { claude: sessions.filter(item => item.session.agent === 'claude').length, codex: sessions.filter(item => item.session.agent === 'codex').length },
89
+ activity,
90
+ trend: snapshots.map(item => ({ label: item.date.slice(5), count: item.total })),
91
+ touchedThisWeek: touched.size,
92
+ weeklyGoal: Math.max(tickets.length, touched.size),
93
+ clearedThisWeek: cleared.size,
94
+ clearGoal: Math.max(snapshots[0]?.total ?? tickets.length, cleared.size),
95
+ recent: recentEvent ? { id: recentEvent.key.slice(provider.length + 1), title: recentTicket?.title ?? `Ticket #${recentEvent.key.slice(provider.length + 1)}`, status: recentTicket?.status, agent: recentEvent.session.agent === 'claude' ? 'Claude' : 'Codex', at: recentEvent.at } : undefined
96
+ };
97
+ }
98
+ function bar(value, max, width = 12) {
99
+ const fill = max ? Math.round(value / max * width) : 0;
100
+ return `${'█'.repeat(fill)}${'░'.repeat(width - fill)}`;
101
+ }
102
+ function ago(value, now) {
103
+ const minutes = Math.max(0, Math.floor((now - value) / 60_000));
104
+ if (minutes < 1)
105
+ return 'just now';
106
+ if (minutes < 60)
107
+ return `${minutes}m ago`;
108
+ const hours = Math.floor(minutes / 60);
109
+ return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`;
110
+ }
111
+ function spark(values) {
112
+ if (!values.length)
113
+ return 'No history yet';
114
+ const levels = [...'▁▂▃▄▅▆▇█'];
115
+ const max = Math.max(...values, 1);
116
+ return values.map(value => levels[Math.round(value / max * (levels.length - 1))]).join(' ');
117
+ }
118
+ function fit(value, width) {
119
+ const chars = [...value];
120
+ return chars.length > width ? `${chars.slice(0, Math.max(0, width - 1)).join('')}…` : value.padEnd(width);
121
+ }
122
+ const defaultPalette = {
123
+ heading: value => bold(accent(value)),
124
+ positive: good,
125
+ warning,
126
+ danger,
127
+ accent,
128
+ bold
129
+ };
130
+ function styledCell(raw, styled, width) {
131
+ return `${styled}${' '.repeat(Math.max(0, width - [...raw].length))}`;
132
+ }
133
+ export function renderDashboard(model, columns = process.stdout.columns ?? 80, now = Date.now(), palette = defaultPalette) {
134
+ const statusMax = Math.max(...model.statuses.map(([, count]) => count), 1);
135
+ const agentMax = Math.max(model.agents.claude, model.agents.codex, 1);
136
+ const width = Math.max(4, Math.min(8, Math.floor(columns / 10)));
137
+ const half = Math.max(30, Math.floor((columns - 5) / 2));
138
+ const statusCells = model.statuses.length ? model.statuses.map(([name, count]) => {
139
+ const chart = bar(count, statusMax, width);
140
+ const number = String(count).padStart(2);
141
+ return {
142
+ raw: `${fit(name, 14)} ${chart} ${number}`,
143
+ styled: `${fit(name, 14)} ${palette.accent(chart)} ${palette.bold(number)}`
144
+ };
145
+ }) : [{ raw: 'No cached tickets yet', styled: 'No cached tickets yet' }];
146
+ const statusRows = [];
147
+ for (let index = 0; index < statusCells.length; index += 2) {
148
+ const first = statusCells[index];
149
+ statusRows.push(` ${styledCell(first.raw, first.styled, half)} ${statusCells[index + 1]?.styled ?? ''}`.trimEnd());
150
+ }
151
+ const trendGraph = spark(model.trend.map(item => item.count));
152
+ const trend = `${palette.accent(trendGraph)}${model.trend.length ? ` ${model.trend.map(item => item.label).join(' ')}` : ''}`;
153
+ const recentMeta = model.recent ? ` · ${model.recent.status ?? 'Status unavailable'} · ${model.recent.agent} · ${ago(model.recent.at, now)}` : '';
154
+ const recent = model.recent
155
+ ? `${palette.accent(`#${model.recent.id}`)} ${palette.bold(fit(model.recent.title, Math.max(10, columns - recentMeta.length - model.recent.id.length - 7)).trimEnd())}${recentMeta}`
156
+ : 'No agent activity yet';
157
+ const claudeRaw = `Claude ${bar(model.agents.claude, agentMax, width)} ${model.agents.claude}`;
158
+ const codexRaw = `Codex ${bar(model.agents.codex, agentMax, width)} ${model.agents.codex}`;
159
+ const claudeStyled = `Claude ${palette.accent(bar(model.agents.claude, agentMax, width))} ${palette.bold(String(model.agents.claude))}`;
160
+ const codexStyled = `Codex ${palette.accent(bar(model.agents.codex, agentMax, width))} ${palette.bold(String(model.agents.codex))}`;
161
+ const activityGraph = spark(model.activity.map(item => item.count));
162
+ const questMessage = model.open === 0
163
+ ? palette.positive('(˶ᵔ ᵕ ᵔ˶) Quest board clear!')
164
+ : model.clearedThisWeek
165
+ ? palette.positive(`( •̀ᴗ•́)⚔ ${model.clearedThisWeek} quest${model.clearedThisWeek === 1 ? '' : 's'} cleared this week!`)
166
+ : palette.warning(`( •̀ᴗ•́)✧ ${model.open} quest${model.open === 1 ? '' : 's'} await you!`);
167
+ const lines = [
168
+ ` ⚔ Open ${palette.warning(String(model.open))} 🐞 Bugs ${palette.danger(String(model.bugs))} 📜 Stories ${palette.accent(String(model.stories))} ✦ Sessions ${palette.positive(String(model.sessions))} ↻ ${model.refreshedAt ? ago(model.refreshedAt, now) : 'never'}`,
169
+ ` ▶ ${palette.positive(String(model.resumable))} ticket${model.resumable === 1 ? '' : 's'} ready to resume`,
170
+ '', ` ${palette.heading('QUEST STATUS')}`,
171
+ ...statusRows,
172
+ '', ` ${palette.heading(fit('AGENT PARTY', half))} ${palette.heading('WEEKLY QUESTS')}`,
173
+ ` ${styledCell(claudeRaw, claudeStyled, half)} Touched [${palette.accent(bar(model.touchedThisWeek, model.weeklyGoal, width))}] ${palette.positive(`${model.touchedThisWeek}/${model.weeklyGoal}`)}`,
174
+ ` ${styledCell(codexRaw, codexStyled, half)} Cleared [${palette.accent(bar(model.clearedThisWeek, model.clearGoal, width))}] ${palette.positive(`${model.clearedThisWeek}/${model.clearGoal}`)} from board`,
175
+ '', ` ${palette.heading(fit('7-DAY ACTIVITY', half))} ${palette.heading('OPEN QUEST TREND')}`,
176
+ ` ${styledCell(model.activity.map(item => item.label).join(' '), model.activity.map(item => item.label).join(' '), half)} ${trend}`,
177
+ ` ${palette.accent(activityGraph)}`,
178
+ '', ` ${palette.heading('LAST QUEST')}`,
179
+ ` ${recent}`,
180
+ '', ` ${questMessage}`
181
+ ];
182
+ return lines.join('\n');
183
+ }
@@ -0,0 +1,53 @@
1
+ import { accent, bold, warning } from './ui.js';
2
+ export function editablePromptConfig(message, current) {
3
+ return {
4
+ message,
5
+ default: current,
6
+ prefill: 'editable',
7
+ validate: value => value.includes('{{id}}') || 'Prompt template must contain {{id}}.'
8
+ };
9
+ }
10
+ function preview(value, max = 52) {
11
+ const chars = [...value];
12
+ return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : value;
13
+ }
14
+ function selectedValue(value) {
15
+ return value === 'Default' ? '(Default)' : value;
16
+ }
17
+ export function launchDefaultsSummary(model, effort) {
18
+ const defaultModel = model === 'Default';
19
+ const defaultEffort = effort === 'Default';
20
+ if (defaultModel && defaultEffort)
21
+ return 'Agent defaults';
22
+ const modelSummary = defaultModel ? 'Agent model' : model;
23
+ const effortSummary = defaultEffort ? 'Agent effort' : `${effort} effort`;
24
+ return `${modelSummary} · ${effortSummary}`;
25
+ }
26
+ export function promptDefaultsSummary(custom) {
27
+ return custom ? 'Custom' : 'Provider default';
28
+ }
29
+ export function launchDefaultsRow(label, summary) {
30
+ return ` ${label.padEnd(18)} ${summary}`;
31
+ }
32
+ export function menuSectionHeader(label, width = 48) {
33
+ const content = ` ${label} `;
34
+ const remaining = Math.max(4, width - [...content].length);
35
+ const left = Math.floor(remaining / 2);
36
+ const right = remaining - left;
37
+ return `${'─'.repeat(left)}${content}${'─'.repeat(right)}`;
38
+ }
39
+ export function buildLaunchMenu(model, effort, prompt, focus = 'start', availability = {}) {
40
+ const modelWarning = availability.model === false ? warning(' · unavailable') : '';
41
+ const effortWarning = availability.effort === false ? warning(' · unavailable') : '';
42
+ return {
43
+ default: focus,
44
+ choices: [
45
+ { name: '▶ Start session', value: 'start' },
46
+ { name: `◈ Model · ${accent(bold(selectedValue(model)))}${modelWarning}`, value: 'model' },
47
+ { name: `✦ Effort · ${warning(bold(selectedValue(effort)))}${effortWarning}`, value: 'effort' },
48
+ { name: `✎ Prompt · ${accent(preview(prompt))}`, value: 'prompt' },
49
+ { name: '★ Save current options as defaults', value: 'save' },
50
+ { name: '↺ Reset prompt to configured default', value: 'reset-prompt' }
51
+ ]
52
+ };
53
+ }
@@ -0,0 +1,91 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { configDir } from './config.js';
5
+ import { isModelId } from './models.js';
6
+ function emptyPreferences() {
7
+ return { agents: { claude: { model: null, effort: null }, codex: { model: null, effort: null } }, prompts: {} };
8
+ }
9
+ function optionalId(value, label) {
10
+ if (value === undefined || value === null)
11
+ return null;
12
+ if (!isModelId(value))
13
+ throw new Error(`Invalid ${label}: ${JSON.stringify(value)}.`);
14
+ return value;
15
+ }
16
+ function validateTemplate(value) {
17
+ if (typeof value !== 'string' || !value.includes('{{id}}'))
18
+ throw new Error('Prompt template must contain {{id}}.');
19
+ return value;
20
+ }
21
+ export class LaunchPreferenceStore {
22
+ path;
23
+ constructor(path = join(configDir, 'launch-preferences.json')) {
24
+ this.path = path;
25
+ }
26
+ async all() {
27
+ let raw;
28
+ try {
29
+ raw = JSON.parse(await readFile(this.path, 'utf8'));
30
+ }
31
+ catch (error) {
32
+ if (error.code === 'ENOENT')
33
+ return emptyPreferences();
34
+ throw error;
35
+ }
36
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
37
+ throw new Error('Invalid launch preferences file.');
38
+ const value = raw;
39
+ const rawAgents = value.agents && typeof value.agents === 'object' && !Array.isArray(value.agents) ? value.agents : {};
40
+ const agents = {};
41
+ for (const agent of ['claude', 'codex']) {
42
+ const entry = rawAgents[agent] && typeof rawAgents[agent] === 'object' && !Array.isArray(rawAgents[agent]) ? rawAgents[agent] : {};
43
+ agents[agent] = { model: optionalId(entry.model, `${agent} model`), effort: optionalId(entry.effort, `${agent} effort`) };
44
+ }
45
+ const prompts = {};
46
+ if (value.prompts !== undefined && (!value.prompts || typeof value.prompts !== 'object' || Array.isArray(value.prompts)))
47
+ throw new Error('Invalid launch prompt preferences.');
48
+ for (const [provider, entry] of Object.entries((value.prompts ?? {}))) {
49
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
50
+ throw new Error(`Invalid prompt preferences for ${provider}.`);
51
+ const source = entry;
52
+ const normalized = {};
53
+ if (source.bug !== undefined)
54
+ normalized.bug = validateTemplate(source.bug);
55
+ if (source.userStory !== undefined)
56
+ normalized.userStory = validateTemplate(source.userStory);
57
+ if (Object.keys(normalized).length)
58
+ prompts[provider] = normalized;
59
+ }
60
+ return { agents, prompts };
61
+ }
62
+ async save(value) {
63
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
64
+ const temp = `${this.path}.${randomUUID()}.tmp`;
65
+ await writeFile(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
66
+ await rename(temp, this.path);
67
+ }
68
+ async setAgent(agent, defaults) {
69
+ const value = await this.all();
70
+ value.agents[agent] = {
71
+ model: optionalId(defaults.model, `${agent} model`),
72
+ effort: optionalId(defaults.effort, `${agent} effort`)
73
+ };
74
+ await this.save(value);
75
+ }
76
+ async setPrompt(provider, kind, template) {
77
+ const value = await this.all();
78
+ if (template === null) {
79
+ if (value.prompts[provider]) {
80
+ delete value.prompts[provider][kind];
81
+ if (!Object.keys(value.prompts[provider]).length)
82
+ delete value.prompts[provider];
83
+ }
84
+ }
85
+ else {
86
+ value.prompts[provider] ??= {};
87
+ value.prompts[provider][kind] = validateTemplate(template);
88
+ }
89
+ await this.save(value);
90
+ }
91
+ }
@@ -0,0 +1,79 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { configDir } from './config.js';
5
+ export function isTicketList(value) {
6
+ return Array.isArray(value) && value.every(item => item && typeof item.id === 'string' && typeof item.provider === 'string' && typeof item.title === 'string' && ['Bug', 'User Story', 'Unsupported'].includes(item.type) && typeof item.typeLabel === 'string' && typeof item.status === 'string' && (item.priority === undefined || (item.priority && typeof item.priority.id === 'string' && typeof item.priority.name === 'string')));
7
+ }
8
+ export function isSavedQueryList(value) {
9
+ return Array.isArray(value) && value.every(item => item && typeof item.id === 'string' && typeof item.name === 'string');
10
+ }
11
+ export class ListCache {
12
+ namespace;
13
+ ttlMs;
14
+ valid;
15
+ root;
16
+ now;
17
+ values = new Map();
18
+ constructor(namespace, ttlMs, valid, root = join(configDir, 'cache'), now = Date.now) {
19
+ this.namespace = namespace;
20
+ this.ttlMs = ttlMs;
21
+ this.valid = valid;
22
+ this.root = root;
23
+ this.now = now;
24
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0)
25
+ throw new Error('Cache TTL must be positive.');
26
+ }
27
+ path(key) {
28
+ const digest = createHash('sha256').update(JSON.stringify([this.namespace, key])).digest('hex');
29
+ return join(this.root, `${digest}.json`);
30
+ }
31
+ fresh(entry) {
32
+ const age = this.now() - entry.fetchedAt;
33
+ return age >= 0 && age < this.ttlMs;
34
+ }
35
+ async read(key) {
36
+ try {
37
+ const entry = JSON.parse(await readFile(this.path(key), 'utf8'));
38
+ if (entry.version === 1 && typeof entry.fetchedAt === 'number' && this.valid(entry.value))
39
+ return entry;
40
+ }
41
+ catch { }
42
+ return undefined;
43
+ }
44
+ async write(key, entry) {
45
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
46
+ const destination = this.path(key);
47
+ const temp = `${destination}.${randomUUID()}.tmp`;
48
+ await writeFile(temp, JSON.stringify(entry), { mode: 0o600, flag: 'wx' });
49
+ await rename(temp, destination);
50
+ }
51
+ async peek(key) {
52
+ const entry = this.values.get(key) ?? await this.read(key);
53
+ return entry ? { fetchedAt: entry.fetchedAt, value: entry.value } : undefined;
54
+ }
55
+ async get(key, load, refresh = false) {
56
+ if (!refresh) {
57
+ const memory = this.values.get(key);
58
+ if (memory && this.fresh(memory))
59
+ return memory.value;
60
+ const disk = await this.read(key);
61
+ if (disk && this.fresh(disk)) {
62
+ this.values.set(key, disk);
63
+ return disk.value;
64
+ }
65
+ }
66
+ const value = await load();
67
+ if (!this.valid(value))
68
+ throw new Error('Invalid list response.');
69
+ const entry = { version: 1, fetchedAt: this.now(), value };
70
+ this.values.set(key, entry);
71
+ try {
72
+ await this.write(key, entry);
73
+ }
74
+ catch {
75
+ console.warn('Could not save the OPAI list cache; this session will still use it.');
76
+ }
77
+ return value;
78
+ }
79
+ }
package/dist/models.js ADDED
@@ -0,0 +1,50 @@
1
+ const CLAUDE_ALIASES = ['sonnet', 'opus', 'haiku'];
2
+ export function isModelId(value) {
3
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/@+\[\]-]{0,199}$/.test(value);
4
+ }
5
+ function validateModelId(agent, value) {
6
+ if (!isModelId(value))
7
+ throw new Error(`Invalid ${agent} model ID: ${JSON.stringify(value)}.`);
8
+ }
9
+ export function parseConfiguredModels(value) {
10
+ if (value === undefined)
11
+ return undefined;
12
+ if (!value || typeof value !== 'object' || Array.isArray(value))
13
+ throw new Error('models must be an object.');
14
+ const models = value;
15
+ for (const agent of ['claude', 'codex']) {
16
+ const entries = models[agent];
17
+ if (entries === undefined)
18
+ continue;
19
+ if (!Array.isArray(entries))
20
+ throw new Error(`models.${agent} must be an array of model IDs.`);
21
+ for (const model of entries)
22
+ validateModelId(agent, model);
23
+ }
24
+ return {
25
+ ...(models.claude !== undefined ? { claude: [...models.claude] } : {}),
26
+ ...(models.codex !== undefined ? { codex: [...models.codex] } : {})
27
+ };
28
+ }
29
+ export function modelOptions(agent, configured = []) {
30
+ for (const model of configured)
31
+ validateModelId(agent, model);
32
+ const models = agent === 'claude' ? [...CLAUDE_ALIASES, ...configured] : configured;
33
+ const unique = [...new Set(models)];
34
+ return [
35
+ { name: 'Default', value: null },
36
+ ...unique.map(value => ({ name: CLAUDE_ALIASES.includes(value) ? value[0].toUpperCase() + value.slice(1) : value, value }))
37
+ ];
38
+ }
39
+ export function resolvePreferredModel(agent, preference, configured = []) {
40
+ if (preference === null)
41
+ return null;
42
+ validateModelId(agent, preference);
43
+ if (!modelOptions(agent, configured).some(option => option.value === preference)) {
44
+ throw new Error(`Saved ${agent} model ${JSON.stringify(preference)} is not available. Change Launch defaults or add it to config.json.`);
45
+ }
46
+ return preference;
47
+ }
48
+ export function modelLabel(model) {
49
+ return model === null ? 'Default' : model;
50
+ }
@@ -0,0 +1,120 @@
1
+ function linkId(link) { const match = link?.href?.match(/\/(\d+)$/); return match ? Number(match[1]) : undefined; }
2
+ export class OpenProjectProvider {
3
+ config;
4
+ token;
5
+ request;
6
+ identity;
7
+ base;
8
+ constructor(config, token, request = fetch) {
9
+ this.config = config;
10
+ this.token = token;
11
+ this.request = request;
12
+ this.identity = `openproject@${config.instanceId}`;
13
+ this.base = config.url.replace(/\/+$/, '');
14
+ }
15
+ normalize(wp) {
16
+ if (!Number.isInteger(wp.id) || !wp.subject || !wp._links)
17
+ throw new Error('Invalid OpenProject work package response.');
18
+ const typeId = linkId(wp._links.type);
19
+ const type = typeId === this.config.bugTypeId ? 'Bug' : typeId === this.config.userStoryTypeId ? 'User Story' : 'Unsupported';
20
+ const typeLabel = wp._links.type?.title ?? 'Unknown';
21
+ const priorityId = linkId(wp._links.priority);
22
+ const priority = priorityId !== undefined || wp._links.priority?.title
23
+ ? { id: priorityId === undefined ? 'unknown' : String(priorityId), name: wp._links.priority?.title ?? 'Unknown' }
24
+ : undefined;
25
+ return { id: String(wp.id), provider: this.identity, title: wp.subject, type, typeLabel, status: wp._links.status?.title ?? 'Unknown', priority, url: `${this.base}/work_packages/${wp.id}` };
26
+ }
27
+ promptTemplate(kind) {
28
+ const configured = this.config.promptTemplates?.[kind];
29
+ const template = configured ?? (kind === 'bug' ? 'fix openproject bug {{id}}' : 'implement openproject user story {{id}}');
30
+ if (typeof template !== 'string' || !template.includes('{{id}}'))
31
+ throw new Error(`OpenProject ${kind} prompt template must contain {{id}}.`);
32
+ return template;
33
+ }
34
+ prompt(ticket, action, override) {
35
+ if (ticket.provider !== this.identity)
36
+ throw new Error('Ticket belongs to another provider.');
37
+ const kind = action === 'fix' && ticket.type === 'Bug' ? 'bug' : action === 'implement' && ticket.type === 'User Story' ? 'userStory' : undefined;
38
+ if (!kind)
39
+ throw new Error(`No ${action} action for ${ticket.type} tickets.`);
40
+ const template = override ?? this.promptTemplate(kind);
41
+ if (typeof template !== 'string' || !template.includes('{{id}}'))
42
+ throw new Error(`OpenProject ${kind} prompt template must contain {{id}}.`);
43
+ return template.replaceAll('{{id}}', ticket.id);
44
+ }
45
+ async getJson(path) {
46
+ let response;
47
+ try {
48
+ response = await this.request(`${this.base}${path}`, { method: 'GET', headers: { Authorization: `Basic ${Buffer.from(`apikey:${this.token}`).toString('base64')}`, Accept: 'application/hal+json' } });
49
+ }
50
+ catch (error) {
51
+ const cause = error.cause?.code;
52
+ throw new Error(`Could not connect to OpenProject at ${this.base}${cause ? ` (${cause})` : ''}. Check openproject.url, DNS, VPN, and TLS.`, { cause: error });
53
+ }
54
+ if (!response.ok)
55
+ throw new Error(`OpenProject API returned HTTP ${response.status}${response.status === 401 ? ' (check API token)' : ''}.`);
56
+ try {
57
+ return await response.json();
58
+ }
59
+ catch {
60
+ throw new Error('OpenProject returned invalid JSON.');
61
+ }
62
+ }
63
+ async listAssigned() {
64
+ const user = await this.getJson('/api/v3/users/me');
65
+ if (!Number.isInteger(user.id))
66
+ throw new Error('OpenProject did not return a user ID.');
67
+ const filters = JSON.stringify([{ assignee: { operator: '=', values: [String(user.id)] } }, { status: { operator: 'o', values: [] } }]);
68
+ const result = [];
69
+ for (let offset = 1;;) {
70
+ const query = new URLSearchParams({ filters, offset: String(offset), pageSize: '100' });
71
+ const page = await this.getJson(`/api/v3/work_packages?${query}`);
72
+ const elements = page._embedded?.elements;
73
+ if (!Array.isArray(elements) || !Number.isInteger(page.total))
74
+ throw new Error('Invalid OpenProject work package collection.');
75
+ result.push(...elements.map(wp => this.normalize(wp)));
76
+ if (!elements.length || result.length >= page.total)
77
+ break;
78
+ offset += 1;
79
+ }
80
+ return result;
81
+ }
82
+ async listSavedQueries() {
83
+ const queries = [];
84
+ for (let offset = 1;; offset++) {
85
+ const params = new URLSearchParams({ offset: String(offset), pageSize: '100' });
86
+ const page = await this.getJson(`/api/v3/queries?${params}`);
87
+ const elements = page._embedded?.elements;
88
+ if (!Array.isArray(elements) || !Number.isInteger(page.total))
89
+ throw new Error('Invalid OpenProject query collection.');
90
+ for (const query of elements) {
91
+ if (!Number.isInteger(query.id) || typeof query.name !== 'string')
92
+ throw new Error('Invalid OpenProject saved query.');
93
+ queries.push({ id: String(query.id), name: query.name });
94
+ }
95
+ if (!elements.length || queries.length >= page.total)
96
+ return queries;
97
+ }
98
+ }
99
+ async listQueryTickets(queryId) {
100
+ if (!/^\d+$/.test(queryId))
101
+ throw new Error('OpenProject query ID must be numeric.');
102
+ const tickets = [];
103
+ for (let offset = 1;; offset++) {
104
+ const params = new URLSearchParams({ offset: String(offset), pageSize: '100' });
105
+ const query = await this.getJson(`/api/v3/queries/${queryId}?${params}`);
106
+ const results = query._embedded?.results;
107
+ const elements = results?._embedded?.elements;
108
+ if (!results || !Array.isArray(elements) || !Number.isInteger(results.total))
109
+ throw new Error('Invalid OpenProject query results.');
110
+ tickets.push(...elements.map(wp => this.normalize(wp)));
111
+ if (!elements.length || tickets.length >= results.total)
112
+ return tickets;
113
+ }
114
+ }
115
+ async get(id) {
116
+ if (!/^\d+$/.test(id))
117
+ throw new Error('OpenProject ticket ID must be numeric.');
118
+ return this.normalize(await this.getJson(`/api/v3/work_packages/${id}`));
119
+ }
120
+ }
@@ -0,0 +1 @@
1
+ export function ticketKey(ticket) { return `${ticket.provider}:${ticket.id}`; }
@@ -0,0 +1,76 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { configDir } from './config.js';
5
+ export function orderedQueries(queries, preferences, provider) {
6
+ return [...queries].sort((a, b) => {
7
+ const left = preferences[`${provider}:${a.id}`];
8
+ const right = preferences[`${provider}:${b.id}`];
9
+ if (Boolean(left?.pinned) !== Boolean(right?.pinned))
10
+ return left?.pinned ? -1 : 1;
11
+ const recent = Date.parse(right?.lastOpenedAt ?? '') - Date.parse(left?.lastOpenedAt ?? '');
12
+ if (Number.isFinite(recent) && recent !== 0)
13
+ return recent;
14
+ if (left?.lastOpenedAt && !right?.lastOpenedAt)
15
+ return -1;
16
+ if (!left?.lastOpenedAt && right?.lastOpenedAt)
17
+ return 1;
18
+ return a.name.localeCompare(b.name);
19
+ });
20
+ }
21
+ export function querySections(queries, preferences, provider) {
22
+ const sections = { pinned: [], recent: [], all: [] };
23
+ for (const query of orderedQueries(queries, preferences, provider)) {
24
+ const preference = preferences[`${provider}:${query.id}`];
25
+ if (preference?.pinned)
26
+ sections.pinned.push(query);
27
+ else if (preference?.lastOpenedAt)
28
+ sections.recent.push(query);
29
+ else
30
+ sections.all.push(query);
31
+ }
32
+ return ['pinned', 'recent', 'all']
33
+ .filter(kind => sections[kind].length > 0)
34
+ .map(kind => ({ kind, queries: sections[kind] }));
35
+ }
36
+ export class QueryPreferencesStore {
37
+ path;
38
+ now;
39
+ constructor(path = join(configDir, 'query-preferences.json'), now = () => new Date()) {
40
+ this.path = path;
41
+ this.now = now;
42
+ }
43
+ async all() {
44
+ try {
45
+ const value = JSON.parse(await readFile(this.path, 'utf8'));
46
+ if (!value || typeof value !== 'object' || Array.isArray(value))
47
+ throw new Error('Invalid query preferences.');
48
+ return value;
49
+ }
50
+ catch (error) {
51
+ if (error.code === 'ENOENT')
52
+ return {};
53
+ throw error;
54
+ }
55
+ }
56
+ async save(value) {
57
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
58
+ const temp = `${this.path}.${randomUUID()}.tmp`;
59
+ await writeFile(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
60
+ await rename(temp, this.path);
61
+ }
62
+ async opened(provider, id) {
63
+ const value = await this.all();
64
+ const key = `${provider}:${id}`;
65
+ value[key] = { pinned: Boolean(value[key]?.pinned), lastOpenedAt: this.now().toISOString() };
66
+ await this.save(value);
67
+ }
68
+ async togglePin(provider, id) {
69
+ const value = await this.all();
70
+ const key = `${provider}:${id}`;
71
+ const pinned = !value[key]?.pinned;
72
+ value[key] = { ...value[key], pinned };
73
+ await this.save(value);
74
+ return pinned;
75
+ }
76
+ }