dsh-composer-plus 1.18.0 → 1.19.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/README.de.md +13 -12
- package/README.es.md +13 -12
- package/README.fr.md +13 -12
- package/README.hi.md +13 -12
- package/README.id.md +13 -12
- package/README.ja.md +13 -12
- package/README.ko.md +13 -12
- package/README.md +13 -12
- package/README.pt-BR.md +13 -12
- package/README.ru.md +13 -12
- package/README.tr.md +13 -12
- package/README.vi.md +13 -12
- package/README.zh.md +13 -12
- package/assets/features/sessionBranch.webp +0 -0
- package/assets/features/taskBoard.webp +0 -0
- package/client.js +424 -620
- package/index.js +167 -506
- package/package.json +3 -2
- package/release-notes.json +18 -0
- package/tether/gateway/server.mjs +1 -1
package/index.js
CHANGED
|
@@ -688,6 +688,19 @@ export const releaseNotes = (body) => {
|
|
|
688
688
|
const text = body.replace(/\r\n?/g, '\n').split(/^## Install\b/m)[0].trim().slice(0, NOTES_MAX);
|
|
689
689
|
return text === '' ? undefined : text;
|
|
690
690
|
};
|
|
691
|
+
/** A UI language id in `release-notes.json` (`en`, `zh`, `pt-BR`); other keys, such as `__proto__`, are not read. */
|
|
692
|
+
const NOTES_LANG_RE = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/;
|
|
693
|
+
/** The release notes that ship with this copy (`release-notes.json`: `{ version, notes: { [language]: markdown } }`), for
|
|
694
|
+
* What's new in the UI language: `{ [language]: text }`, or undefined when the file is for another version or has no
|
|
695
|
+
* text. The copy that runs after an update has them; the older copy that installed it saved only GitHub's English body. */
|
|
696
|
+
export const shippedNotes = (text, version = PACKAGE.version) => {
|
|
697
|
+
let data;
|
|
698
|
+
try { data = JSON.parse(text); } catch { return undefined; }
|
|
699
|
+
if (data?.version !== version || data.notes === null || typeof data.notes !== 'object') return undefined;
|
|
700
|
+
const notes = Object.fromEntries(Object.entries(data.notes).filter(([id]) => NOTES_LANG_RE.test(id))
|
|
701
|
+
.map(([id, body]) => [id, releaseNotes(body)]).filter(([, body]) => body !== undefined));
|
|
702
|
+
return Object.keys(notes).length === 0 ? undefined : notes;
|
|
703
|
+
};
|
|
691
704
|
/** Latest GitHub release vs this copy. `managed` is false for a renamed or linked local copy, which pnpm can't upgrade. */
|
|
692
705
|
export async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
|
|
693
706
|
const current = parseVersion(pkg.version);
|
|
@@ -1898,8 +1911,12 @@ export function apply(ctx) {
|
|
|
1898
1911
|
ctx.connection.fetch.register({
|
|
1899
1912
|
path: UPDATE_PATH, methods: ['GET'], requestBody: 'buffered',
|
|
1900
1913
|
fetch: async (request) => {
|
|
1901
|
-
// `?current=1`:
|
|
1902
|
-
|
|
1914
|
+
// `?current=1`: the version that this Host runs and its shipped release notes (What's new after an update); no
|
|
1915
|
+
// network call. A missing or broken notes file leaves `notes` out.
|
|
1916
|
+
if (new URL(request.url).searchParams.get('current') === '1') {
|
|
1917
|
+
const notes = await readFile(new URL('./release-notes.json', import.meta.url), 'utf8').then(shippedNotes, () => undefined);
|
|
1918
|
+
return json(notes === undefined ? { current: PACKAGE.version } : { current: PACKAGE.version, notes });
|
|
1919
|
+
}
|
|
1903
1920
|
try { return json(await checkUpdate(fetch, PACKAGE, request.signal)); } catch (error) { return json({ error: String(error?.message ?? error) }, 502); }
|
|
1904
1921
|
},
|
|
1905
1922
|
});
|
|
@@ -6416,124 +6433,76 @@ export function installBackup(ctx, home, { graceMs = AUTO_BACKUP_GRACE_MS, retry
|
|
|
6416
6433
|
return { settled: () => new Promise((resolve) => { const wait = () => (busy === null ? resolve() : setTimeout(wait, 10)); wait(); }), tick };
|
|
6417
6434
|
}
|
|
6418
6435
|
|
|
6419
|
-
/** Task board (Host half): a kanban board of
|
|
6420
|
-
*
|
|
6421
|
-
* `storages/dsh-composer-plus/board.json
|
|
6422
|
-
* A
|
|
6423
|
-
*
|
|
6424
|
-
* position) over all projects: a new task and a status change go to the bottom, a drop before a card goes between it and
|
|
6425
|
-
* the card above it. */
|
|
6436
|
+
/** Task board (Host half): a kanban board of dsh's real chats. The client draws the cards from dsh's own stores (the chat
|
|
6437
|
+
* list, each chat's live state and the projects); the Host keeps the switch and the record of the chats that the board
|
|
6438
|
+
* started: `storages/dsh-composer-plus/board-chats.json`, `{ chats: { [sessionId]: { prompt, tags, at } } }`, owner-only.
|
|
6439
|
+
* A board start makes a new chat in a dsh project with the user's prompt (dsh's own title service names the chat); one
|
|
6440
|
+
* short call to the new chat's model writes its tags. The old notebook board (`board.json`) is not read or changed. */
|
|
6426
6441
|
export const BOARD_PATH = '/api/composer-plus.board';
|
|
6427
|
-
export const
|
|
6428
|
-
export const
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
const
|
|
6433
|
-
const BOARD_SHOWN_MAX = 2000;
|
|
6434
|
-
export const BOARD_SECTION = `# Task board
|
|
6435
|
-
This project has open tasks on the user's task board (Composer Plus → Task board). Tasks look like api#12. Keep the board current without being asked:
|
|
6436
|
-
- When you start a task from the board, move it to doing (board_next_task takes the top todo task and moves it).
|
|
6437
|
-
- Comment on what you did with board_add_comment: the result, the files, the checks and their outcome.
|
|
6438
|
-
- Move the task to review when you finish it or when you are blocked. Never move a task to done: the user does that.
|
|
6439
|
-
- Leave human tasks and backlog tasks to the user.`;
|
|
6440
|
-
|
|
6441
|
-
const boardStatuses = (data) => (data.backlog ? BOARD_STATUSES : BOARD_STATUSES.slice(1));
|
|
6442
|
-
/** One line: runs of white space become one space, the ends are trimmed. */
|
|
6443
|
-
const boardLine = (raw) => String(raw ?? '').replace(/\s+/g, ' ').trim();
|
|
6444
|
-
/** Comma-separated tags: each one trimmed, lower case, one space between words; no empty or repeated tag; first seen first. */
|
|
6445
|
-
export const boardTags = (raw) => {
|
|
6446
|
-
const list = Array.isArray(raw) ? raw : String(raw ?? '').split(',');
|
|
6447
|
-
const out = [];
|
|
6448
|
-
for (const part of list) { const tag = boardLine(part).toLowerCase(); if (tag !== '' && !out.includes(tag)) out.push(tag); }
|
|
6449
|
-
if (out.length > BOARD_LIMITS.tags) throw refused(`a task has at most ${BOARD_LIMITS.tags} tags`, 400);
|
|
6450
|
-
if (out.some((tag) => tag.length > BOARD_LIMITS.tag)) throw refused(`a tag has at most ${BOARD_LIMITS.tag} characters`, 400);
|
|
6451
|
-
return out;
|
|
6452
|
-
};
|
|
6453
|
-
/** A short id for a project name: lower case a–z, 0–9 and "-", at most 24 characters; a taken one gets -2, -3, … */
|
|
6454
|
-
export const boardSlug = (name, taken) => {
|
|
6455
|
-
const base = String(name ?? '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24).replace(/-+$/, '') || 'project';
|
|
6456
|
-
if (!taken.has(base)) return base;
|
|
6457
|
-
for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
|
|
6458
|
-
};
|
|
6442
|
+
export const BOARD_CHATS_PATH = '/api/composer-plus.board-chats';
|
|
6443
|
+
export const BOARD_LIMITS = { prompt: 20000, tags: 3, tag: 24, chats: 2000 };
|
|
6444
|
+
const BOARD_TAG_INPUT = 4000;
|
|
6445
|
+
const BOARD_TAG_TOKENS = 200;
|
|
6446
|
+
const BOARD_TAG_TIMEOUT_MS = 60000;
|
|
6447
|
+
export const BOARD_TAG_SYSTEM = `You label a coding task for a kanban board. Read the user's request and answer with only a JSON array of 1 to ${BOARD_LIMITS.tags} short tags, for example ["frontend","bug"]. Each tag is one or two lower-case words, at most ${BOARD_LIMITS.tag} characters, in the language of the request. No other text.`;
|
|
6459
6448
|
const BRANCH_DIR = /^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/].*)?$/;
|
|
6460
6449
|
const trimSlash = (p) => (p.length > 1 ? p.replace(/[\\/]+$/, '') : p);
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
let
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
function cleanBoard(raw) {
|
|
6478
|
-
if (!plainObject(raw)) throw new Error('the board file is not a JSON object');
|
|
6479
|
-
const data = emptyBoard();
|
|
6480
|
-
data.backlog = raw.backlog === true;
|
|
6481
|
-
const slugs = new Set();
|
|
6482
|
-
for (const p of Array.isArray(raw.projects) ? raw.projects : []) {
|
|
6483
|
-
if (!isText(p?.path, 4096) || !isAbsolute(p.path) || !/^[a-z0-9-]{1,32}$/.test(p?.slug ?? '') || slugs.has(p.slug) || data.projects.some((x) => x.path === p.path)) continue;
|
|
6484
|
-
slugs.add(p.slug); data.projects.push({ path: p.path, slug: p.slug });
|
|
6485
|
-
}
|
|
6486
|
-
const ids = new Set();
|
|
6487
|
-
for (const t of Array.isArray(raw.tasks) ? raw.tasks : []) {
|
|
6488
|
-
if (!Number.isSafeInteger(t?.id) || t.id < 1 || ids.has(t.id) || !data.projects.some((p) => p.path === t.path) || !isText(t.title, BOARD_LIMITS.title)
|
|
6489
|
-
|| !BOARD_STATUSES.includes(t.status) || !Number.isFinite(t.position) || !isTime(t.created) || !isTime(t.updated)) continue;
|
|
6490
|
-
ids.add(t.id);
|
|
6491
|
-
const comments = (Array.isArray(t.comments) ? t.comments : []).filter((c) => Number.isSafeInteger(c?.id) && (c.by === 'user' || c.by === 'agent')
|
|
6492
|
-
&& isText(c.body, BOARD_LIMITS.comment) && isTime(c.at) && (c.sessionId === undefined || isText(c.sessionId, 128)))
|
|
6493
|
-
.map((c) => ({ id: c.id, by: c.by, ...(c.sessionId === undefined ? {} : { sessionId: c.sessionId }), body: c.body, at: c.at }));
|
|
6494
|
-
data.tasks.push({ id: t.id, path: t.path, title: t.title, description: typeof t.description === 'string' ? t.description.slice(0, BOARD_LIMITS.description) : '',
|
|
6495
|
-
tags: Array.isArray(t.tags) ? t.tags.filter((x) => isText(x, BOARD_LIMITS.tag)).slice(0, BOARD_LIMITS.tags) : [], human: t.human === true,
|
|
6496
|
-
status: t.status, position: t.position, created: t.created, updated: t.updated, comments });
|
|
6450
|
+
const boardLine = (raw) => String(raw ?? '').replace(/\s+/g, ' ').trim();
|
|
6451
|
+
|
|
6452
|
+
/** The model's tags: the first JSON array of strings, each one line, lower case, at most BOARD_LIMITS.tag characters, no
|
|
6453
|
+
* repeats, at most BOARD_LIMITS.tags. Any other answer gives []. */
|
|
6454
|
+
export function boardTags(text) {
|
|
6455
|
+
const m = /\[[\s\S]*?\]/.exec(String(text ?? '').replace(/<think>[\s\S]*?<\/think>/g, ''));
|
|
6456
|
+
let list;
|
|
6457
|
+
try { list = m === null ? [] : JSON.parse(m[0]); } catch { list = []; }
|
|
6458
|
+
if (!Array.isArray(list)) return [];
|
|
6459
|
+
const out = [];
|
|
6460
|
+
for (const item of list) {
|
|
6461
|
+
if (typeof item !== 'string') continue;
|
|
6462
|
+
const tag = boardLine(item).replace(/^[#"'`\-*\s]+|["'`.\s]+$/g, '').toLowerCase();
|
|
6463
|
+
if (tag === '' || tag.length > BOARD_LIMITS.tag || out.includes(tag)) continue;
|
|
6464
|
+
out.push(tag);
|
|
6465
|
+
if (out.length === BOARD_LIMITS.tags) break;
|
|
6497
6466
|
}
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
* after the write (all or none). A broken file is kept as `board.json.broken-<time>` and the board starts empty; a file
|
|
6507
|
-
* that cannot be read (other than missing) stops every change, so nothing overwrites it. */
|
|
6508
|
-
export function boardStore(file, { now = () => new Date() } = {}) {
|
|
6509
|
-
let data = emptyBoard();
|
|
6467
|
+
return out;
|
|
6468
|
+
}
|
|
6469
|
+
|
|
6470
|
+
/** The record of the board's chats: one change at a time, merged into the newest data (a start never drops another
|
|
6471
|
+
* start's entry), each write atomic and owner-only. A missing file is empty; a broken file is kept as
|
|
6472
|
+
* `board-chats.json.broken-<time>` and the record starts empty. At most BOARD_LIMITS.chats entries (the oldest go). */
|
|
6473
|
+
export function boardChatsStore(file) {
|
|
6474
|
+
let chats = {};
|
|
6510
6475
|
let rev = Date.now();
|
|
6511
6476
|
const ready = (async () => {
|
|
6512
6477
|
let text;
|
|
6513
6478
|
try { text = await readFile(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return; throw error; }
|
|
6514
|
-
try {
|
|
6479
|
+
try {
|
|
6480
|
+
const raw = JSON.parse(text)?.chats;
|
|
6481
|
+
if (!plainObject(raw)) throw new Error('not an object');
|
|
6482
|
+
for (const [id, c] of Object.entries(raw)) {
|
|
6483
|
+
if (!isText(id, 128) || id === '__proto__' || !plainObject(c) || typeof c.prompt !== 'string' || !Number.isFinite(c.at)) continue;
|
|
6484
|
+
chats[id] = { prompt: c.prompt.slice(0, BOARD_LIMITS.prompt), tags: Array.isArray(c.tags) ? c.tags.filter((t) => isText(t, BOARD_LIMITS.tag)).slice(0, BOARD_LIMITS.tags) : [], at: c.at };
|
|
6485
|
+
}
|
|
6486
|
+
} catch { chats = {}; await rename(file, `${file}.broken-${Date.now()}`).catch(() => {}); }
|
|
6515
6487
|
})();
|
|
6516
6488
|
let queue = ready.catch(() => {});
|
|
6517
|
-
const write = async (next) => {
|
|
6518
|
-
await mkdir(dirname(file), { recursive: true });
|
|
6519
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
6520
|
-
await writeFile(tmp, JSON.stringify(next), { mode: 0o600 });
|
|
6521
|
-
await rename(tmp, file);
|
|
6522
|
-
};
|
|
6523
6489
|
return {
|
|
6524
6490
|
ready,
|
|
6525
|
-
/** The board now (do not change it). Empty until the file is read. */
|
|
6526
|
-
peek: () => data,
|
|
6527
6491
|
rev: () => rev,
|
|
6528
|
-
|
|
6492
|
+
list: () => chats,
|
|
6493
|
+
/** `fn(copy)` changes a copy of the record; the copy becomes the record only after the write. */
|
|
6529
6494
|
change(fn) {
|
|
6530
6495
|
const done = queue.then(async () => {
|
|
6531
6496
|
await ready;
|
|
6532
|
-
const
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6497
|
+
const next = { ...chats };
|
|
6498
|
+
fn(next);
|
|
6499
|
+
const ids = Object.keys(next);
|
|
6500
|
+
if (ids.length > BOARD_LIMITS.chats) for (const id of ids.sort((a, b) => next[a].at - next[b].at).slice(0, ids.length - BOARD_LIMITS.chats)) delete next[id];
|
|
6501
|
+
await mkdir(dirname(file), { recursive: true });
|
|
6502
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
6503
|
+
await writeFile(tmp, JSON.stringify({ chats: next }), { mode: 0o600 });
|
|
6504
|
+
await rename(tmp, file);
|
|
6505
|
+
chats = next; rev += 1;
|
|
6537
6506
|
});
|
|
6538
6507
|
queue = done.catch(() => {});
|
|
6539
6508
|
return done;
|
|
@@ -6541,438 +6510,130 @@ export function boardStore(file, { now = () => new Date() } = {}) {
|
|
|
6541
6510
|
};
|
|
6542
6511
|
}
|
|
6543
6512
|
|
|
6544
|
-
/** The
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
const path = trimSlash(w.path);
|
|
6551
|
-
if (!list.has(path)) list.set(path, { path, name: (typeof w.title === 'string' && w.title.trim()) || basename(path) || path });
|
|
6552
|
-
}
|
|
6553
|
-
for (const p of data.projects) if (!list.has(p.path)) list.set(p.path, { path: p.path, name: basename(p.path) || p.path });
|
|
6554
|
-
const taken = new Set(data.projects.map((p) => p.slug));
|
|
6555
|
-
const out = [...list.values()].sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
|
|
6556
|
-
for (const p of out) {
|
|
6557
|
-
const saved = data.projects.find((x) => x.path === p.path)?.slug;
|
|
6558
|
-
p.slug = saved ?? boardSlug(p.name, taken);
|
|
6559
|
-
taken.add(p.slug);
|
|
6560
|
-
const own = data.tasks.filter((t) => t.path === p.path);
|
|
6561
|
-
p.open = own.filter((t) => t.status !== 'done').length;
|
|
6562
|
-
p.total = own.length;
|
|
6563
|
-
}
|
|
6564
|
-
return out;
|
|
6513
|
+
/** The dsh project of a board start, by its workspace id: a registered workspace that is not a Branch per chat folder. */
|
|
6514
|
+
export function boardProject(registry, id) {
|
|
6515
|
+
if (!isText(id)) return undefined;
|
|
6516
|
+
let list;
|
|
6517
|
+
try { list = registry?.list?.() ?? []; } catch { list = []; }
|
|
6518
|
+
return list.find((w) => w?.id === id && isText(w.path, 4096) && !BRANCH_DIR.test(trimSlash(w.path)));
|
|
6565
6519
|
}
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
const
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
}
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
const t = data.tasks.find((x) => x.id === id);
|
|
6585
|
-
if (t === undefined) return undefined;
|
|
6586
|
-
const projects = boardProjects(data, registry);
|
|
6587
|
-
return { ...boardCard(t, slugMap(projects)), description: t.description, comments: t.comments, project: projects.find((p) => p.path === t.path)?.name ?? basename(t.path) };
|
|
6588
|
-
}
|
|
6589
|
-
/** Search every project: each word must be in the key, title, description or tags. Open tasks first, then the newest change. */
|
|
6590
|
-
export function boardSearch(data, registry, query) {
|
|
6591
|
-
const slugs = slugMap(boardProjects(data, registry));
|
|
6592
|
-
const words = boardLine(query).toLowerCase().split(' ').filter(Boolean).slice(0, 10);
|
|
6593
|
-
const hits = data.tasks.filter((t) => { const text = `${keyOf(t, slugs)} ${t.title} ${t.description} ${t.tags.join(' ')}`.toLowerCase(); return words.every((w) => text.includes(w)); });
|
|
6594
|
-
hits.sort((a, b) => (a.status === 'done') - (b.status === 'done') || Date.parse(b.updated) - Date.parse(a.updated) || b.id - a.id);
|
|
6595
|
-
return hits.slice(0, BOARD_LIMITS.search).map((t) => boardCard(t, slugs));
|
|
6596
|
-
}
|
|
6597
|
-
const utcTime = (iso) => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
6598
|
-
/** The board (one project or all) as Markdown or JSON, with every done task and every comment. */
|
|
6599
|
-
export function boardExport(data, registry, project, format, at = new Date().toISOString()) {
|
|
6600
|
-
const projects = boardProjects(data, registry).filter((p) => (project === 'all' ? p.total > 0 : p.path === project));
|
|
6601
|
-
const statuses = boardStatuses(data);
|
|
6602
|
-
const tasksOf = (p) => data.tasks.filter((t) => t.path === p.path).sort((a, b) => statuses.indexOf(a.status) - statuses.indexOf(b.status) || byPosition(a, b));
|
|
6603
|
-
if (format === 'json') {
|
|
6604
|
-
return JSON.stringify({ exported: at, statuses, projects: projects.map((p) => ({ name: p.name, slug: p.slug, path: p.path, open: p.open, total: p.total,
|
|
6605
|
-
tasks: tasksOf(p).map((t) => ({ key: `${p.slug}#${t.id}`, id: t.id, title: t.title, description: t.description, tags: t.tags, human: t.human, status: t.status,
|
|
6606
|
-
created: t.created, updated: t.updated, comments: t.comments })) })) }, null, 2);
|
|
6520
|
+
|
|
6521
|
+
/** One short call to `route` for the tags of a prompt; [] when the call fails or answers nothing usable. */
|
|
6522
|
+
export async function boardTagsFor({ llm, route }, prompt, signal) {
|
|
6523
|
+
if (llm?.prepareCall === undefined || route === undefined) return [];
|
|
6524
|
+
const timeout = AbortSignal.timeout(BOARD_TAG_TIMEOUT_MS);
|
|
6525
|
+
const callSignal = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
6526
|
+
const call = await llm.prepareCall(route, callSignal, (controls, model) => {
|
|
6527
|
+
// The lowest reasoning effort keeps it fast; the output cap never goes above the model's own default.
|
|
6528
|
+
const first = model?.reasoning?.efforts?.[0];
|
|
6529
|
+
const cap = Number.isSafeInteger(model?.defaultMaxTokens) && model.defaultMaxTokens > 0 ? Math.min(BOARD_TAG_TOKENS, model.defaultMaxTokens) : BOARD_TAG_TOKENS;
|
|
6530
|
+
return { ...controls, maxTokens: cap, ...(first === undefined ? {} : { reasoningEffort: first.id }) };
|
|
6531
|
+
});
|
|
6532
|
+
let text = '';
|
|
6533
|
+
let finish;
|
|
6534
|
+
for await (const chunk of call.stream({ ...call.config, system: BOARD_TAG_SYSTEM, signal: callSignal,
|
|
6535
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: String(prompt).slice(0, BOARD_TAG_INPUT) }] }] })) {
|
|
6536
|
+
if (chunk.type === 'text-delta') text += chunk.text;
|
|
6537
|
+
else if (chunk.type === 'finish') finish = chunk.reason;
|
|
6607
6538
|
}
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6539
|
+
if (finish?.kind === 'error' || finish?.kind === 'aborted') throw new Error(finish.failure?.message || `the model call ended: ${finish.kind}`);
|
|
6540
|
+
return boardTags(text);
|
|
6541
|
+
}
|
|
6542
|
+
|
|
6543
|
+
/** Start a board task: a new chat in the project's dsh workspace with `prompt` as its first message (a `/command` that dsh
|
|
6544
|
+
* knows runs as that command), then the record. All or none: when the prompt cannot be sent, the new chat is archived and
|
|
6545
|
+
* the error says so. The prompt is sent with its own timeout, not the request's signal, so a closed request does not leave
|
|
6546
|
+
* an empty chat. Returns `{ sessionId, agent }`. */
|
|
6547
|
+
export async function startBoardChat({ sessionController, commands, registry, store }, project, prompt) {
|
|
6548
|
+
const text = String(prompt ?? '').trim();
|
|
6549
|
+
if (text === '' || text.length > BOARD_LIMITS.prompt) throw refused(`a task needs a prompt of at most ${BOARD_LIMITS.prompt} characters`, 400);
|
|
6550
|
+
const workspace = boardProject(registry, project);
|
|
6551
|
+
if (workspace === undefined) throw refused('this is not a dsh project', 404);
|
|
6552
|
+
const { sessionId } = await sessionController.create({ workspaceId: workspace.id });
|
|
6553
|
+
let agent;
|
|
6554
|
+
try {
|
|
6555
|
+
const found = await sessionController.resolveAgent(sessionId);
|
|
6556
|
+
if ('error' in found) throw new Error('the new chat could not start');
|
|
6557
|
+
agent = found.agent;
|
|
6558
|
+
const name = /^\/([\w:.-]+)(?:\s|$)/.exec(text)?.[1];
|
|
6559
|
+
let done;
|
|
6560
|
+
if (name !== undefined && commands?.find?.(agent, name) !== undefined) {
|
|
6561
|
+
done = await commands.execute(agent, text, [], AbortSignal.timeout(STOP_WAIT_MS));
|
|
6562
|
+
if (done?.result?.kind !== 'success') throw new Error(`/${name} failed${done?.result?.text ? `: ${done.result.text}` : ''}`);
|
|
6563
|
+
} else await sessionController.prompt({ requestId: randomUUID(), sessionId, mode: 'queue', content: [{ type: 'text', text }] }, AbortSignal.timeout(STOP_WAIT_MS));
|
|
6564
|
+
await store.change((chats) => { chats[sessionId] = { prompt: text, tags: [], at: Date.now() }; });
|
|
6565
|
+
} catch (error) {
|
|
6566
|
+
const undone = await Promise.resolve(registry?.archiveSession?.(sessionId, { stopActivity: true })).then(() => registry?.archiveSession !== undefined, () => false);
|
|
6567
|
+
throw Object.assign(new Error(`${error?.message ?? error}; ${undone ? 'the new chat was archived' : `the new chat ${sessionId} stays empty`}`), { status: Number.isInteger(error?.status) ? error.status : 502 });
|
|
6626
6568
|
}
|
|
6627
|
-
return
|
|
6628
|
-
}
|
|
6629
|
-
|
|
6630
|
-
/** The changes of the board, on a draft (`boardStore.change`). Each throws `refused` to change nothing. */
|
|
6631
|
-
const bottomOf = (d, status) => Math.floor(Math.max(0, ...d.tasks.filter((t) => t.status === status).map((t) => t.position))) + 1;
|
|
6632
|
-
const findTask = (d, id) => { const t = d.tasks.find((x) => x.id === id); if (t === undefined) throw refused(`no task #${id}`, 404); return t; };
|
|
6633
|
-
const checkStatus = (d, status) => { if (!boardStatuses(d).includes(status)) throw refused(`unknown status "${String(status).slice(0, 40)}"; use one of: ${boardStatuses(d).join(', ')}`, 400); return status; };
|
|
6634
|
-
const checkTitle = (raw) => { const title = boardLine(raw); if (title === '') throw refused('a task needs a title', 400); if (title.length > BOARD_LIMITS.title) throw refused(`a title has at most ${BOARD_LIMITS.title} characters`, 400); return title; };
|
|
6635
|
-
const checkText = (raw) => { const text = String(raw ?? '').trim(); if (text.length > BOARD_LIMITS.description) throw refused(`a description has at most ${BOARD_LIMITS.description} characters`, 400); return text; };
|
|
6636
|
-
/** The project's saved slug; a project gets one with its first task. */
|
|
6637
|
-
function ensureProject(d, path, registry) {
|
|
6638
|
-
const saved = d.projects.find((p) => p.path === path);
|
|
6639
|
-
if (saved !== undefined) return saved.slug;
|
|
6640
|
-
const listed = boardProjects(d, registry).find((p) => p.path === path);
|
|
6641
|
-
const slug = listed?.slug ?? boardSlug(basename(path), new Set(d.projects.map((p) => p.slug)));
|
|
6642
|
-
d.projects.push({ path, slug });
|
|
6643
|
-
return slug;
|
|
6644
|
-
}
|
|
6645
|
-
export const boardOps = {
|
|
6646
|
-
create(d, now, { path, title, description = '', tags = [], human = false, status = 'todo' }, registry) {
|
|
6647
|
-
if (d.tasks.length >= BOARD_LIMITS.tasks) throw refused(`the board keeps at most ${BOARD_LIMITS.tasks} tasks; delete old done tasks first`);
|
|
6648
|
-
const task = { id: d.next, path, title: checkTitle(title), description: checkText(description), tags: boardTags(tags), human: human === true,
|
|
6649
|
-
status: checkStatus(d, status), position: 0, created: now, updated: now, comments: [] };
|
|
6650
|
-
task.position = bottomOf(d, task.status);
|
|
6651
|
-
ensureProject(d, path, registry);
|
|
6652
|
-
d.next += 1; d.tasks.push(task);
|
|
6653
|
-
return task.id;
|
|
6654
|
-
},
|
|
6655
|
-
/** `was`: the values that the editor started from; a field that changed meanwhile (an agent, another window) refuses the
|
|
6656
|
-
* whole change with 409. */
|
|
6657
|
-
update(d, now, id, patch, was = {}, registry = []) {
|
|
6658
|
-
const t = findTask(d, id);
|
|
6659
|
-
const same = { title: (v) => t.title === boardLine(v), description: (v) => t.description === String(v ?? '').trim(), tags: (v) => t.tags.join(',') === boardTags(v).join(',') };
|
|
6660
|
-
for (const [field, value] of Object.entries(was)) if (same[field] !== undefined && !same[field](value)) throw refused(`the ${field} changed meanwhile`, 409);
|
|
6661
|
-
let changed = false;
|
|
6662
|
-
if (patch.title !== undefined) { const v = checkTitle(patch.title); changed ||= v !== t.title; t.title = v; }
|
|
6663
|
-
if (patch.description !== undefined) { const v = checkText(patch.description); changed ||= v !== t.description; t.description = v; }
|
|
6664
|
-
if (patch.tags !== undefined) { const v = boardTags(patch.tags); changed ||= v.join(',') !== t.tags.join(','); t.tags = v; }
|
|
6665
|
-
if (patch.human !== undefined) { changed ||= t.human !== (patch.human === true); t.human = patch.human === true; }
|
|
6666
|
-
if (patch.path !== undefined && patch.path !== t.path) { ensureProject(d, patch.path, registry); t.path = patch.path; changed = true; }
|
|
6667
|
-
if (patch.status !== undefined && checkStatus(d, patch.status) !== t.status) { t.position = bottomOf(d, patch.status); t.status = patch.status; changed = true; }
|
|
6668
|
-
if (changed) t.updated = now;
|
|
6669
|
-
return t.id;
|
|
6670
|
-
},
|
|
6671
|
-
/** A drag: to the bottom of `status`, or just above the task `before` in that column. */
|
|
6672
|
-
move(d, now, id, status, before) {
|
|
6673
|
-
const t = findTask(d, id);
|
|
6674
|
-
checkStatus(d, status);
|
|
6675
|
-
const column = () => d.tasks.filter((x) => x.status === status && x.id !== id).sort(byPosition);
|
|
6676
|
-
const target = before === undefined ? undefined : column().find((x) => x.id === before);
|
|
6677
|
-
if (t.status !== status) t.updated = now;
|
|
6678
|
-
t.status = status;
|
|
6679
|
-
if (target === undefined) { t.position = bottomOf({ tasks: column() }, status); return t.id; }
|
|
6680
|
-
const place = () => { const list = column(); const i = list.indexOf(target); return i <= 0 ? [undefined, target] : [list[i - 1], target]; };
|
|
6681
|
-
let [above] = place();
|
|
6682
|
-
if (above !== undefined && target.position - above.position < 1e-9) { column().forEach((x, i) => { x.position = i + 1; }); [above] = place(); }
|
|
6683
|
-
t.position = above === undefined ? target.position - 1 : (above.position + target.position) / 2;
|
|
6684
|
-
return t.id;
|
|
6685
|
-
},
|
|
6686
|
-
comment(d, now, id, { body, by, sessionId }) {
|
|
6687
|
-
const t = findTask(d, id);
|
|
6688
|
-
const text = String(body ?? '').trim();
|
|
6689
|
-
if (text === '') throw refused('a comment needs text', 400);
|
|
6690
|
-
if (text.length > BOARD_LIMITS.comment) throw refused(`a comment has at most ${BOARD_LIMITS.comment} characters`, 400);
|
|
6691
|
-
if (t.comments.length >= BOARD_LIMITS.comments) throw refused(`a task keeps at most ${BOARD_LIMITS.comments} comments`);
|
|
6692
|
-
t.comments.push({ id: d.nextComment, by, ...(isText(sessionId, 128) ? { sessionId } : {}), body: text, at: now });
|
|
6693
|
-
d.nextComment += 1;
|
|
6694
|
-
return t.id;
|
|
6695
|
-
},
|
|
6696
|
-
uncomment(d, id, commentId) {
|
|
6697
|
-
const t = findTask(d, id);
|
|
6698
|
-
const at = t.comments.findIndex((c) => c.id === commentId);
|
|
6699
|
-
if (at < 0) throw refused('no such comment', 404);
|
|
6700
|
-
t.comments.splice(at, 1);
|
|
6701
|
-
return t.id;
|
|
6702
|
-
},
|
|
6703
|
-
remove(d, id) { const t = findTask(d, id); d.tasks.splice(d.tasks.indexOf(t), 1); return t; },
|
|
6704
|
-
/** The top todo task that is not for a person, moved to the bottom of doing; undefined when there is none. */
|
|
6705
|
-
next(d, now, path) {
|
|
6706
|
-
const t = d.tasks.filter((x) => x.status === 'todo' && !x.human && (path === 'all' || x.path === path)).sort(byPosition)[0];
|
|
6707
|
-
if (t === undefined) return undefined;
|
|
6708
|
-
t.position = bottomOf(d, 'doing'); t.status = 'doing'; t.updated = now;
|
|
6709
|
-
return t.id;
|
|
6710
|
-
},
|
|
6711
|
-
};
|
|
6569
|
+
return { sessionId, agent };
|
|
6570
|
+
}
|
|
6712
6571
|
|
|
6713
|
-
/**
|
|
6714
|
-
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
6715
|
-
const boardTaskLine = (t, slugs) => `${keyOf(t, slugs)} [${t.status}] ${t.title}${t.human ? ' (human)' : ''}${t.tags.length > 0 ? ` {${t.tags.join(', ')}}` : ''}${t.comments.length > 0 ? ` (${plural(t.comments.length, 'comment')})` : ''}`;
|
|
6716
|
-
function boardTaskText(t, slugs) {
|
|
6717
|
-
const lines = [`${keyOf(t, slugs)} ${t.title}${t.tags.length > 0 ? ` {${t.tags.join(', ')}}` : ''}`,
|
|
6718
|
-
`status: ${t.status}${t.human ? ', human: for the user, not for agents' : ''}, created ${utcTime(t.created)}${t.updated !== t.created ? `, updated ${utcTime(t.updated)}` : ''}`];
|
|
6719
|
-
if (t.description !== '') lines.push('', t.description);
|
|
6720
|
-
if (t.comments.length > 0) lines.push('', `Comments (${t.comments.length}):`, ...t.comments.map((c) => `- ${c.by === 'agent' ? 'agent' : 'user'}, ${utcTime(c.at)}: ${c.body}`));
|
|
6721
|
-
return lines.join('\n');
|
|
6722
|
-
}
|
|
6723
|
-
/** `api#12`, `#12`, `12` or 12; a slug that is not the task's project finds nothing. */
|
|
6724
|
-
export function boardKey(raw, data, slugs) {
|
|
6725
|
-
const m = /^\s*(?:([a-z0-9-]{1,32})?#)?(\d{1,15})\s*$/i.exec(String(raw ?? ''));
|
|
6726
|
-
if (m === null) throw refused(`"${String(raw).slice(0, 40)}" is not a task: use a key like api#12`, 400);
|
|
6727
|
-
const t = findTask(data, Number(m[2]));
|
|
6728
|
-
if (m[1] !== undefined && slugs.get(t.path) !== m[1].toLowerCase()) throw refused(`no task ${raw}`, 404);
|
|
6729
|
-
return t.id;
|
|
6730
|
-
}
|
|
6731
|
-
|
|
6732
|
-
/** Task board (Host half): the switch route, the board route, and while the switch is on the tools and the prompt section. */
|
|
6572
|
+
/** Task board (Host half): the switch route and, while it is on, the board's chat route. */
|
|
6733
6573
|
export function installBoard(ctx, home, dir = join(home, 'storages', 'dsh-composer-plus')) {
|
|
6734
6574
|
const get = (name) => { try { return ctx.get?.(name); } catch { return undefined; } };
|
|
6735
|
-
const store =
|
|
6575
|
+
const store = boardChatsStore(join(dir, 'board-chats.json'));
|
|
6736
6576
|
const sw = mobileSwitch(join(dir, 'board-switch.json'), true);
|
|
6737
|
-
const registry = () => { try { return get('workspaceRegistry')?.list?.() ?? []; } catch { return []; } };
|
|
6738
|
-
const listing = () => boardProjects(store.peek(), registry());
|
|
6739
6577
|
let on = true;
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
const
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
return BOARD_SECTION;
|
|
6758
|
-
};
|
|
6759
|
-
/** The project that a tool names (`project`: a slug, a name or "all"), else the calling chat's project. */
|
|
6760
|
-
const projectArg = (exec, raw, allowAll) => {
|
|
6761
|
-
const projects = listing();
|
|
6762
|
-
const name = boardLine(raw).toLowerCase();
|
|
6763
|
-
if (name === 'all') { if (allowAll) return 'all'; throw refused('name one project, not "all"', 400); }
|
|
6764
|
-
if (name !== '') {
|
|
6765
|
-
const hit = projects.find((p) => p.slug === name) ?? projects.find((p) => p.name.toLowerCase() === name);
|
|
6766
|
-
if (hit === undefined) throw refused(`no project "${String(raw).slice(0, 60)}"; projects: ${projects.map((p) => p.slug).join(', ') || 'none'}`, 404);
|
|
6767
|
-
return hit.path;
|
|
6768
|
-
}
|
|
6769
|
-
const cwd = folderOf(exec?.agent);
|
|
6770
|
-
if (cwd === undefined) throw refused('this chat has no project folder: pass `project` (see board_list_projects)', 400);
|
|
6771
|
-
return boardProjectOf(cwd, projects.map((p) => p.path));
|
|
6772
|
-
};
|
|
6773
|
-
const slugs = () => slugMap(listing());
|
|
6774
|
-
const keyParam = { type: 'string', description: 'The task, like api#12' };
|
|
6775
|
-
const projectParam = { type: 'string', description: 'A project slug or name, or "all". Default: the project of this chat' };
|
|
6776
|
-
const tools = [
|
|
6777
|
-
{ name: 'board_list_projects', description: 'List the projects of the task board with their slugs and open task counts.',
|
|
6778
|
-
parameters: { type: 'object', properties: {} }, output: textOutput,
|
|
6779
|
-
async execute(_args, exec) {
|
|
6780
|
-
await store.ready;
|
|
6781
|
-
const projects = listing();
|
|
6782
|
-
let here; try { here = projectArg(exec, '', false); } catch { here = undefined; }
|
|
6783
|
-
return projects.length === 0 ? 'No projects.' : projects.map((p) => `${p.slug}${p.slug !== p.name ? ` "${p.name}"` : ''}: ${p.open} open of ${p.total}${p.path === here ? ' <- this chat' : ''}`).join('\n');
|
|
6784
|
-
} },
|
|
6785
|
-
{ name: 'board_list_tasks', description: 'List the tasks of the task board in board order. Without status, done and human tasks are left out.',
|
|
6786
|
-
parameters: { type: 'object', properties: { project: projectParam, status: { type: 'string', description: 'One column (backlog, todo, doing, review, done), or "all"' } } }, output: textOutput,
|
|
6787
|
-
async execute(args, exec) {
|
|
6788
|
-
await store.ready;
|
|
6789
|
-
const d = store.peek(); const path = projectArg(exec, args?.project, true); const s = slugs();
|
|
6790
|
-
const status = boardLine(args?.status).toLowerCase();
|
|
6791
|
-
if (status !== '' && status !== 'all') checkStatus(d, status);
|
|
6792
|
-
const statuses = boardStatuses(d);
|
|
6793
|
-
const own = d.tasks.filter((t) => (path === 'all' || t.path === path) && (status === '' || status === 'all' || t.status === status))
|
|
6794
|
-
.sort((a, b) => statuses.indexOf(a.status) - statuses.indexOf(b.status) || byPosition(a, b));
|
|
6795
|
-
const human = own.filter((t) => t.human).length;
|
|
6796
|
-
const done = own.filter((t) => t.status === 'done');
|
|
6797
|
-
const shownDone = status === '' ? [] : done.slice(-BOARD_AGENT_DONE);
|
|
6798
|
-
const list = own.filter((t) => !t.human && (t.status !== 'done' || shownDone.includes(t)));
|
|
6799
|
-
const lines = list.map((t) => boardTaskLine(t, s));
|
|
6800
|
-
if (lines.length === 0) lines.push(`No tasks in ${path === 'all' ? 'any project' : s.get(path) ?? basename(path)}.`);
|
|
6801
|
-
if (human > 0) lines.push(`(${plural(human, 'human task')} not shown: for the user, not for agents)`);
|
|
6802
|
-
const left = done.filter((t) => !t.human).length - shownDone.filter((t) => !t.human).length;
|
|
6803
|
-
if (left > 0) lines.push(status === '' ? `(${left} done not shown; pass status "done" or "all")` : `(${left} older done not shown)`);
|
|
6804
|
-
return lines.join('\n');
|
|
6805
|
-
} },
|
|
6806
|
-
{ name: 'board_get_task', description: 'Show one task of the task board with its description and comments.',
|
|
6807
|
-
parameters: { type: 'object', properties: { id: keyParam }, required: ['id'] }, output: textOutput,
|
|
6808
|
-
async execute(args) { await store.ready; const d = store.peek(); const s = slugs(); return boardTaskText(findTask(d, boardKey(args?.id, d, s)), s); } },
|
|
6809
|
-
{ name: 'board_create_task', description: 'Add a task to the task board, at the bottom of todo unless you pass status.',
|
|
6810
|
-
parameters: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string', description: 'Markdown' }, project: { type: 'string', description: 'A project slug or name. Default: the project of this chat' },
|
|
6811
|
-
tags: { type: 'string', description: 'Comma-separated' }, status: { type: 'string', description: 'A column; default todo' }, human: { type: 'boolean', description: 'For the user, not for agents' } }, required: ['title'] }, output: textOutput,
|
|
6812
|
-
async execute(args, exec) {
|
|
6813
|
-
await store.ready;
|
|
6814
|
-
const path = projectArg(exec, args?.project, false);
|
|
6815
|
-
const id = await store.change((d, now) => boardOps.create(d, now, { path, title: args?.title, description: args?.description, tags: args?.tags, human: args?.human, status: boardLine(args?.status ?? 'todo').toLowerCase() || 'todo' }, registry()));
|
|
6816
|
-
const t = store.peek().tasks.find((x) => x.id === id);
|
|
6817
|
-
return `Created ${keyOf(t, slugs())} in ${t.status}: ${t.title}`;
|
|
6818
|
-
} },
|
|
6819
|
-
{ name: 'board_update_task', description: 'Change the fields that you pass of one task of the task board. A new status puts it at the bottom of that column.',
|
|
6820
|
-
parameters: { type: 'object', properties: { id: keyParam, title: { type: 'string' }, description: { type: 'string', description: 'Markdown' }, status: { type: 'string', description: 'A column, like review' },
|
|
6821
|
-
tags: { type: 'string', description: 'Comma-separated; "" removes all' }, human: { type: 'boolean' } }, required: ['id'] }, output: textOutput,
|
|
6822
|
-
async execute(args) {
|
|
6823
|
-
await store.ready;
|
|
6824
|
-
let before;
|
|
6825
|
-
const id = await store.change((d, now) => {
|
|
6826
|
-
const tid = boardKey(args?.id, d, slugMap(boardProjects(d, registry())));
|
|
6827
|
-
before = d.tasks.find((x) => x.id === tid).status;
|
|
6828
|
-
return boardOps.update(d, now, tid, { title: args?.title, description: args?.description, tags: args?.tags, human: typeof args?.human === 'boolean' ? args.human : undefined,
|
|
6829
|
-
status: args?.status === undefined ? undefined : boardLine(args.status).toLowerCase() });
|
|
6830
|
-
});
|
|
6831
|
-
const t = store.peek().tasks.find((x) => x.id === id);
|
|
6832
|
-
return `Updated ${keyOf(t, slugs())}${before !== t.status ? `, ${before} -> ${t.status}` : ''}: ${t.title}`;
|
|
6833
|
-
} },
|
|
6834
|
-
{ name: 'board_add_comment', description: 'Comment on a task of the task board, for example to report what you did.',
|
|
6835
|
-
parameters: { type: 'object', properties: { id: keyParam, body: { type: 'string', description: 'Markdown' } }, required: ['id', 'body'] }, output: textOutput,
|
|
6836
|
-
async execute(args, exec) {
|
|
6837
|
-
await store.ready;
|
|
6838
|
-
const id = await store.change((d, now) => boardOps.comment(d, now, boardKey(args?.id, d, slugMap(boardProjects(d, registry()))), { body: args?.body, by: 'agent', sessionId: exec?.agent?.id }));
|
|
6839
|
-
return `Comment added to ${keyOf(store.peek().tasks.find((x) => x.id === id), slugs())}.`;
|
|
6840
|
-
} },
|
|
6841
|
-
{ name: 'board_next_task', description: 'Take the top todo task of the task board that is not for a person, move it to doing and show it.',
|
|
6842
|
-
parameters: { type: 'object', properties: { project: projectParam } }, output: textOutput,
|
|
6843
|
-
async execute(args, exec) {
|
|
6844
|
-
await store.ready;
|
|
6845
|
-
const path = projectArg(exec, args?.project, true);
|
|
6846
|
-
const id = await store.change((d, now) => { const tid = boardOps.next(d, now, path); if (tid === undefined) throw Object.assign(new Error('none'), { none: true }); return tid; })
|
|
6847
|
-
.catch((error) => { if (error?.none) return undefined; throw error; });
|
|
6848
|
-
const s = slugs();
|
|
6849
|
-
if (id === undefined) return `Nothing in todo for ${path === 'all' ? 'any project' : s.get(path) ?? basename(path)}.`;
|
|
6850
|
-
return `Moved to doing:\n${boardTaskText(store.peek().tasks.find((x) => x.id === id), s)}`;
|
|
6851
|
-
} },
|
|
6852
|
-
{ name: 'board_delete_task', description: 'Delete a task of the task board with its comments. This cannot be undone.',
|
|
6853
|
-
parameters: { type: 'object', properties: { id: keyParam }, required: ['id'] }, output: textOutput,
|
|
6854
|
-
async execute(args) {
|
|
6855
|
-
await store.ready;
|
|
6856
|
-
const s = slugs();
|
|
6857
|
-
const t = await store.change((d) => boardOps.remove(d, boardKey(args?.id, d, s)));
|
|
6858
|
-
return `Deleted ${keyOf(t, s)}${t.comments.length > 0 ? ` and its ${plural(t.comments.length, 'comment')}` : ''}: ${t.title}`;
|
|
6859
|
-
} },
|
|
6860
|
-
];
|
|
6861
|
-
const apply = (next) => {
|
|
6862
|
-
on = next;
|
|
6863
|
-
if (on && fiber === undefined && typeof ctx.inject === 'function') {
|
|
6864
|
-
fiber = ctx.inject(['tools', 'systemPrompt'], (sub) => {
|
|
6865
|
-
for (const tool of tools) sub.tools.register(tool);
|
|
6866
|
-
sub.systemPrompt.section({ name: 'composer-plus:board', order: 655, interpolate: false, text: (context) => sectionFor(context?.agent) });
|
|
6867
|
-
});
|
|
6868
|
-
} else if (!on) { fiber?.dispose?.(); fiber = undefined; shown.clear(); }
|
|
6578
|
+
const ready = sw.read().then((v) => { on = v; }, () => {});
|
|
6579
|
+
let closed = false;
|
|
6580
|
+
const tagging = new Set(); // background tag calls; unload stops them
|
|
6581
|
+
ctx.effect?.(() => () => { closed = true; for (const c of tagging) c.abort(); }, 'dsh-composer-plus: task board');
|
|
6582
|
+
/** The tags of a new chat, in the background: the chat's own model (its selection), else dsh's default. */
|
|
6583
|
+
const tag = async (sessionId, agent, prompt) => {
|
|
6584
|
+
const llm = get('llm');
|
|
6585
|
+
if (llm?.prepareCall === undefined || closed) return;
|
|
6586
|
+
const mine = new AbortController();
|
|
6587
|
+
tagging.add(mine);
|
|
6588
|
+
try {
|
|
6589
|
+
const current = ctx.sessionController.agents?.selectionFor?.(agent)?.current;
|
|
6590
|
+
const route = isText(current?.provider) && isText(current?.model) ? { provider: current.provider, model: current.model } : await routeOf(ctx, get);
|
|
6591
|
+
const tags = await boardTagsFor({ llm, route }, prompt, mine.signal);
|
|
6592
|
+
if (tags.length > 0 && !closed) await store.change((chats) => { if (chats[sessionId] !== undefined) chats[sessionId] = { ...chats[sessionId], tags }; });
|
|
6593
|
+
} catch (error) { if (!closed) ctx.logger?.warn?.(`dsh-composer-plus: task board tags failed: ${String(error?.message ?? error).slice(0, 300)}`); }
|
|
6594
|
+
finally { tagging.delete(mine); }
|
|
6869
6595
|
};
|
|
6870
|
-
const ready = sw.read().then(apply, () => apply(true));
|
|
6871
|
-
ctx.effect?.(() => () => { on = false; fiber?.dispose?.(); fiber = undefined; }, 'dsh-composer-plus: task board');
|
|
6872
6596
|
let switching = ready;
|
|
6873
6597
|
ctx.connection.fetch.register({
|
|
6874
6598
|
path: BOARD_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
6875
6599
|
fetch: async (request) => {
|
|
6876
6600
|
await ready;
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
await store.change((d) => {
|
|
6889
|
-
if (!body.backlog && d.tasks.some((t) => t.status === 'backlog')) throw refused(`move the ${plural(d.tasks.filter((t) => t.status === 'backlog').length, 'task')} out of Backlog first`);
|
|
6890
|
-
d.backlog = body.backlog;
|
|
6891
|
-
});
|
|
6892
|
-
}
|
|
6893
|
-
}
|
|
6894
|
-
await store.ready.catch(() => {});
|
|
6895
|
-
return json({ on, backlog: store.peek().backlog });
|
|
6896
|
-
} catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
|
|
6601
|
+
if (request.method === 'POST') {
|
|
6602
|
+
let body;
|
|
6603
|
+
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
6604
|
+
if (!plainObject(body) || Object.keys(body).join() !== 'on' || typeof body.on !== 'boolean') return json({ error: 'bad request' }, 400);
|
|
6605
|
+
try {
|
|
6606
|
+
const change = switching.then(async () => { await sw.write(body.on); on = body.on; });
|
|
6607
|
+
switching = change.catch(() => {});
|
|
6608
|
+
await change;
|
|
6609
|
+
} catch (error) { return json({ error: String(error?.message ?? error) }, 500); }
|
|
6610
|
+
}
|
|
6611
|
+
return json({ on });
|
|
6897
6612
|
},
|
|
6898
6613
|
});
|
|
6899
|
-
/**
|
|
6900
|
-
* `?sessionId=` (the view of that chat's project), `?task=<id>`, `?q=<words>`, `?export=markdown|json&project=`. POST
|
|
6901
|
-
* `{op, …}`: create, update, move, comment, uncomment, delete. While the switch is off: 409. */
|
|
6614
|
+
/** GET `?rev=N`: `{rev, chats}` (`{rev, same}` when nothing changed since rev). POST `{project, prompt}`: start a chat. */
|
|
6902
6615
|
ctx.connection.fetch.register({
|
|
6903
|
-
path:
|
|
6616
|
+
path: BOARD_CHATS_PATH, methods: ['GET', 'POST'], requestBody: 'buffered',
|
|
6904
6617
|
fetch: async (request) => {
|
|
6905
6618
|
await ready;
|
|
6906
6619
|
if (!on) return json({ error: 'Task board is off' }, 409);
|
|
6907
6620
|
try {
|
|
6908
6621
|
await store.ready;
|
|
6909
|
-
const known = (path) => isText(path, 4096) && listing().some((p) => p.path === path);
|
|
6910
6622
|
if (request.method === 'GET') {
|
|
6911
|
-
const q = new URL(request.url).searchParams;
|
|
6912
6623
|
const rev = store.rev();
|
|
6913
|
-
if (
|
|
6914
|
-
|
|
6915
|
-
return task === undefined ? json({ error: 'this task was deleted', rev }, 404) : json({ rev, task });
|
|
6916
|
-
}
|
|
6917
|
-
if (q.has('q')) return json({ rev, results: boardSearch(store.peek(), registry(), String(q.get('q')).slice(0, 200)) });
|
|
6918
|
-
let project = q.get('project') ?? 'all';
|
|
6919
|
-
if (q.has('sessionId')) {
|
|
6920
|
-
// The open chat's project; a chat without a folder, or a folder that is no board project, shows every project.
|
|
6921
|
-
const cwd = await chatFolder(ctx.sessionQuery, q.get('sessionId'), request.signal).catch(() => undefined);
|
|
6922
|
-
project = cwd === undefined ? 'all' : boardProjectOf(cwd, listing().map((p) => p.path));
|
|
6923
|
-
if (!known(project)) project = 'all';
|
|
6924
|
-
} else if (project !== 'all' && !known(project)) return json({ error: 'unknown project' }, 404);
|
|
6925
|
-
if (q.has('export')) {
|
|
6926
|
-
const format = q.get('export') === 'json' ? 'json' : 'markdown';
|
|
6927
|
-
const name = project === 'all' ? 'task-board' : `task-board-${listing().find((p) => p.path === project)?.slug ?? 'project'}`;
|
|
6928
|
-
return json({ name: `${name}.${format === 'json' ? 'json' : 'md'}`, text: boardExport(store.peek(), registry(), project, format) });
|
|
6929
|
-
}
|
|
6930
|
-
if (q.get('rev') === String(rev)) return json({ rev, same: true });
|
|
6931
|
-
return json({ rev, ...boardView(store.peek(), registry(), { project, done: q.get('done') === 'all' }) });
|
|
6624
|
+
if (new URL(request.url).searchParams.get('rev') === String(rev)) return json({ rev, same: true });
|
|
6625
|
+
return json({ rev, chats: store.list() });
|
|
6932
6626
|
}
|
|
6933
6627
|
let body;
|
|
6934
6628
|
try { body = await request.json(); } catch { return json({ error: 'bad request' }, 400); }
|
|
6935
|
-
if (!plainObject(body)) return json({ error: 'bad request' }, 400);
|
|
6936
|
-
const
|
|
6937
|
-
|
|
6938
|
-
|
|
6939
|
-
&& (body.human === undefined || typeof body.human === 'boolean') && text(body.status, 20);
|
|
6940
|
-
let result;
|
|
6941
|
-
switch (body.op) {
|
|
6942
|
-
case 'create':
|
|
6943
|
-
if (!fieldsOk || !known(body.project) || typeof body.title !== 'string') return json({ error: 'bad request' }, 400);
|
|
6944
|
-
result = await store.change((d, now) => boardOps.create(d, now, { path: body.project, title: body.title, description: body.description, tags: body.tags, human: body.human, status: body.status ?? 'todo' }, registry()));
|
|
6945
|
-
break;
|
|
6946
|
-
case 'update': {
|
|
6947
|
-
const was = body.was ?? {};
|
|
6948
|
-
if (id === undefined || !fieldsOk || !plainObject(was) || !Object.entries(was).every(([k, v]) => ['title', 'description', 'tags'].includes(k) && typeof v === 'string')
|
|
6949
|
-
|| (body.project !== undefined && !known(body.project))) return json({ error: 'bad request' }, 400);
|
|
6950
|
-
result = await store.change((d, now) => boardOps.update(d, now, id, { title: body.title, description: body.description, tags: body.tags, human: body.human, status: body.status, path: body.project }, was, registry()));
|
|
6951
|
-
break;
|
|
6952
|
-
}
|
|
6953
|
-
case 'move':
|
|
6954
|
-
if (id === undefined || typeof body.status !== 'string' || (body.before !== undefined && !Number.isSafeInteger(body.before))) return json({ error: 'bad request' }, 400);
|
|
6955
|
-
result = await store.change((d, now) => boardOps.move(d, now, id, body.status, body.before));
|
|
6956
|
-
break;
|
|
6957
|
-
case 'comment':
|
|
6958
|
-
if (id === undefined || typeof body.body !== 'string' || body.body.length > BOARD_LIMITS.comment + 1000) return json({ error: 'bad request' }, 400);
|
|
6959
|
-
result = await store.change((d, now) => boardOps.comment(d, now, id, { body: body.body, by: 'user' }));
|
|
6960
|
-
break;
|
|
6961
|
-
case 'uncomment':
|
|
6962
|
-
if (id === undefined || !Number.isSafeInteger(body.comment)) return json({ error: 'bad request' }, 400);
|
|
6963
|
-
result = await store.change((d) => boardOps.uncomment(d, id, body.comment));
|
|
6964
|
-
break;
|
|
6965
|
-
case 'delete':
|
|
6966
|
-
if (id === undefined) return json({ error: 'bad request' }, 400);
|
|
6967
|
-
await store.change((d) => boardOps.remove(d, id));
|
|
6968
|
-
return json({ rev: store.rev(), deleted: id });
|
|
6969
|
-
default: return json({ error: 'bad request' }, 400);
|
|
6970
|
-
}
|
|
6971
|
-
return json({ rev: store.rev(), task: boardTask(store.peek(), registry(), result) });
|
|
6629
|
+
if (!plainObject(body) || !isText(body.project) || typeof body.prompt !== 'string' || body.prompt.length > BOARD_LIMITS.prompt + 1000) return json({ error: 'bad request' }, 400);
|
|
6630
|
+
const { sessionId, agent } = await startBoardChat({ sessionController: ctx.sessionController, commands: get('commands'), registry: get('workspaceRegistry'), store }, body.project, body.prompt);
|
|
6631
|
+
tag(sessionId, agent, body.prompt.trim());
|
|
6632
|
+
return json({ rev: store.rev(), sessionId });
|
|
6972
6633
|
} catch (error) { return json({ error: String(error?.message ?? error) }, Number.isInteger(error?.status) ? error.status : 500); }
|
|
6973
6634
|
},
|
|
6974
6635
|
});
|
|
6975
|
-
return { store, ready,
|
|
6636
|
+
return { store, ready, tag, isOn: () => on };
|
|
6976
6637
|
}
|
|
6977
6638
|
|
|
6978
6639
|
/* Planned steps, after HarnessMM (arXiv 2610.32813, "guarded decision reuse"): the model plans its next commands with the
|