@dennisrongo/dsh-todo 0.4.0 → 0.5.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.md +132 -26
- package/lib/bin.js +57 -14
- package/lib/cli.js +57 -14
- package/lib/client.js +217 -34
- package/lib/index.js +421 -11
- package/lib/launch.js +109 -0
- package/lib/scan.js +276 -0
- package/lib/suggest.js +93 -0
- package/lib/typert.host.js +59 -1
- package/package.json +5 -3
package/lib/index.js
CHANGED
|
@@ -48,8 +48,8 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
|
|
|
48
48
|
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
|
|
49
49
|
|
|
50
50
|
// src/index.ts
|
|
51
|
-
import { readFileSync, renameSync, existsSync } from "node:fs";
|
|
52
|
-
import { isAbsolute, join as
|
|
51
|
+
import { readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync, existsSync, unlinkSync } from "node:fs";
|
|
52
|
+
import { isAbsolute, join as join3, resolve as resolve2 } from "node:path";
|
|
53
53
|
import { Service } from "@deepseek-ai/cordis";
|
|
54
54
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
55
55
|
import { z } from "zod";
|
|
@@ -92,6 +92,14 @@ var MAX_TEXT = 500;
|
|
|
92
92
|
var MAX_DESC = 5e3;
|
|
93
93
|
var MAX_LABEL = 60;
|
|
94
94
|
var MAX_ITEMS = 1e3;
|
|
95
|
+
var SUGGESTIONS_DIR = ".dsh";
|
|
96
|
+
var SUGGESTIONS_FILE = `${SUGGESTIONS_DIR}/suggestions.json`;
|
|
97
|
+
var SUGGESTIONS_FILE_RE = /^suggestions(-[a-z0-9]+)?\.json$/;
|
|
98
|
+
function suggestionsFileFor(runId) {
|
|
99
|
+
return `${SUGGESTIONS_DIR}/suggestions-${runId}.json`;
|
|
100
|
+
}
|
|
101
|
+
__name(suggestionsFileFor, "suggestionsFileFor");
|
|
102
|
+
var MAX_SUGGESTIONS = 12;
|
|
95
103
|
|
|
96
104
|
// src/db.ts
|
|
97
105
|
var DOT_DSH = ".dsh";
|
|
@@ -135,6 +143,7 @@ function migrateSchema(db) {
|
|
|
135
143
|
add("release", "release TEXT");
|
|
136
144
|
add("sprint", "sprint TEXT");
|
|
137
145
|
add("due_date", "due_date TEXT");
|
|
146
|
+
add("session_id", "session_id TEXT");
|
|
138
147
|
if (addedTitle && columns.has("text")) {
|
|
139
148
|
db.exec("UPDATE todo SET title = text WHERE title IS NULL");
|
|
140
149
|
}
|
|
@@ -151,7 +160,7 @@ function readList(db) {
|
|
|
151
160
|
const updatedAt = Number(db.prepare("SELECT value FROM meta WHERE key = 'updatedAt'").get()?.value ?? 0);
|
|
152
161
|
const rows = db.prepare(
|
|
153
162
|
`SELECT id, title, description, status, priority, release, sprint, due_date,
|
|
154
|
-
created_at, completed_at, archived_at
|
|
163
|
+
session_id, created_at, completed_at, archived_at
|
|
155
164
|
FROM todo ORDER BY position ASC`
|
|
156
165
|
).all();
|
|
157
166
|
const text = /* @__PURE__ */ __name((v) => v === null || v === void 0 ? void 0 : String(v), "text");
|
|
@@ -166,6 +175,7 @@ function readList(db) {
|
|
|
166
175
|
...normalizeLabel(row.release) !== void 0 ? { release: normalizeLabel(row.release) } : {},
|
|
167
176
|
...normalizeLabel(row.sprint) !== void 0 ? { sprint: normalizeLabel(row.sprint) } : {},
|
|
168
177
|
...normalizeDueDate(row.due_date) !== void 0 ? { dueDate: normalizeDueDate(row.due_date) } : {},
|
|
178
|
+
...text(row.session_id) !== void 0 ? { sessionId: text(row.session_id) } : {},
|
|
169
179
|
createdAt: Number(row.created_at),
|
|
170
180
|
...row.completed_at !== null && row.completed_at !== void 0 ? { completedAt: Number(row.completed_at) } : {},
|
|
171
181
|
...row.archived_at !== null && row.archived_at !== void 0 ? { archivedAt: Number(row.archived_at) } : {}
|
|
@@ -180,8 +190,8 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
180
190
|
db.prepare("DELETE FROM todo").run();
|
|
181
191
|
const insert = db.prepare(
|
|
182
192
|
`INSERT INTO todo (id, title, description, status, priority, release, sprint, due_date,
|
|
183
|
-
text, done, created_at, completed_at, archived_at, position)
|
|
184
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
193
|
+
session_id, text, done, created_at, completed_at, archived_at, position)
|
|
194
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
185
195
|
);
|
|
186
196
|
items.forEach((item, index) => {
|
|
187
197
|
insert.run(
|
|
@@ -193,6 +203,7 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
193
203
|
item.release ?? null,
|
|
194
204
|
item.sprint ?? null,
|
|
195
205
|
item.dueDate ?? null,
|
|
206
|
+
item.sessionId ?? null,
|
|
196
207
|
item.title,
|
|
197
208
|
item.status === "done" ? 1 : 0,
|
|
198
209
|
item.createdAt,
|
|
@@ -213,6 +224,337 @@ function writeList(db, items, revision, updatedAt = Date.now()) {
|
|
|
213
224
|
}
|
|
214
225
|
__name(writeList, "writeList");
|
|
215
226
|
|
|
227
|
+
// src/scan.ts
|
|
228
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
229
|
+
import { join as join2, relative, sep } from "node:path";
|
|
230
|
+
var DIGEST_BYTE_CAP = 24e3;
|
|
231
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
232
|
+
".git",
|
|
233
|
+
".hg",
|
|
234
|
+
".svn",
|
|
235
|
+
"node_modules",
|
|
236
|
+
"bower_components",
|
|
237
|
+
"jspm_packages",
|
|
238
|
+
"lib",
|
|
239
|
+
"dist",
|
|
240
|
+
"build",
|
|
241
|
+
"out",
|
|
242
|
+
"coverage",
|
|
243
|
+
".next",
|
|
244
|
+
".nuxt",
|
|
245
|
+
".svelte-kit",
|
|
246
|
+
".output",
|
|
247
|
+
".parcel-cache",
|
|
248
|
+
".turbo",
|
|
249
|
+
".cache",
|
|
250
|
+
".venv",
|
|
251
|
+
"venv",
|
|
252
|
+
"__pycache__",
|
|
253
|
+
".tox",
|
|
254
|
+
".mypy_cache",
|
|
255
|
+
".pytest_cache",
|
|
256
|
+
"target",
|
|
257
|
+
"vendor",
|
|
258
|
+
"vendored",
|
|
259
|
+
"third_party",
|
|
260
|
+
"thirdparty",
|
|
261
|
+
"generated",
|
|
262
|
+
"__generated__",
|
|
263
|
+
"Pods",
|
|
264
|
+
"Carthage",
|
|
265
|
+
"DerivedData"
|
|
266
|
+
]);
|
|
267
|
+
var SOURCE_EXT = /* @__PURE__ */ new Set([
|
|
268
|
+
".ts",
|
|
269
|
+
".tsx",
|
|
270
|
+
".js",
|
|
271
|
+
".jsx",
|
|
272
|
+
".mjs",
|
|
273
|
+
".cjs",
|
|
274
|
+
".py",
|
|
275
|
+
".go",
|
|
276
|
+
".rs",
|
|
277
|
+
".java",
|
|
278
|
+
".rb",
|
|
279
|
+
".php",
|
|
280
|
+
".cs",
|
|
281
|
+
".swift",
|
|
282
|
+
".kt",
|
|
283
|
+
".scala",
|
|
284
|
+
".sh"
|
|
285
|
+
]);
|
|
286
|
+
var MAX_FILES_WALKED = 4e3;
|
|
287
|
+
var MAX_TREE_ENTRIES = 300;
|
|
288
|
+
var MAX_COMMENTS = 80;
|
|
289
|
+
var MAX_UNTESTED = 40;
|
|
290
|
+
var MAX_COMMENT_LINE = 160;
|
|
291
|
+
var README_BYTES = 4e3;
|
|
292
|
+
var MANIFEST_BYTES = 2e3;
|
|
293
|
+
var MAX_DEPTH = 8;
|
|
294
|
+
var SCAN_CEILING_FACTOR = 10;
|
|
295
|
+
var MAX_FILES_READ = 400;
|
|
296
|
+
var MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
297
|
+
function posix(path) {
|
|
298
|
+
return path.split(sep).join("/");
|
|
299
|
+
}
|
|
300
|
+
__name(posix, "posix");
|
|
301
|
+
function walk(root) {
|
|
302
|
+
const files = [];
|
|
303
|
+
let truncated = false;
|
|
304
|
+
const visit = /* @__PURE__ */ __name((dir, depth) => {
|
|
305
|
+
if (depth > MAX_DEPTH || files.length >= MAX_FILES_WALKED) {
|
|
306
|
+
truncated = true;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
let entries;
|
|
310
|
+
try {
|
|
311
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
312
|
+
} catch {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
if (files.length >= MAX_FILES_WALKED) {
|
|
317
|
+
truncated = true;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
322
|
+
visit(join2(dir, entry.name), depth + 1);
|
|
323
|
+
} else if (entry.isFile()) {
|
|
324
|
+
files.push(posix(relative(root, join2(dir, entry.name))));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}, "visit");
|
|
328
|
+
try {
|
|
329
|
+
if (!statSync(root).isDirectory()) return { files: [], truncated: false };
|
|
330
|
+
} catch {
|
|
331
|
+
return { files: [], truncated: false };
|
|
332
|
+
}
|
|
333
|
+
visit(root, 0);
|
|
334
|
+
return { files, truncated };
|
|
335
|
+
}
|
|
336
|
+
__name(walk, "walk");
|
|
337
|
+
function readText(path, limit = Number.MAX_SAFE_INTEGER) {
|
|
338
|
+
let raw;
|
|
339
|
+
try {
|
|
340
|
+
if (statSync(path).size > MAX_READ_BYTES) return "";
|
|
341
|
+
raw = readFileSync(path, "utf8");
|
|
342
|
+
} catch {
|
|
343
|
+
return "";
|
|
344
|
+
}
|
|
345
|
+
if (raw.includes("\0")) return "";
|
|
346
|
+
return raw.length > limit ? raw.slice(0, limit) : raw;
|
|
347
|
+
}
|
|
348
|
+
__name(readText, "readText");
|
|
349
|
+
function skippedForSize(path) {
|
|
350
|
+
try {
|
|
351
|
+
return statSync(path).size > MAX_READ_BYTES;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
__name(skippedForSize, "skippedForSize");
|
|
357
|
+
var COMMENT_RE = /(?:^|\s)(?:\/\/|#|\/\*|\*)\s*(TODO|FIXME|HACK)\b[:\s]?(.*)$/;
|
|
358
|
+
function collectComments(root, files) {
|
|
359
|
+
const ceiling = MAX_COMMENTS * SCAN_CEILING_FACTOR;
|
|
360
|
+
const kept = [];
|
|
361
|
+
let total = 0;
|
|
362
|
+
let read = 0;
|
|
363
|
+
let skipped = 0;
|
|
364
|
+
let bounded = false;
|
|
365
|
+
for (const rel of files) {
|
|
366
|
+
const dot = rel.lastIndexOf(".");
|
|
367
|
+
if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
|
|
368
|
+
if (total >= ceiling || read >= MAX_FILES_READ) {
|
|
369
|
+
bounded = true;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
const full = join2(root, rel);
|
|
373
|
+
if (skippedForSize(full)) {
|
|
374
|
+
skipped += 1;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
read += 1;
|
|
378
|
+
const text = readText(full);
|
|
379
|
+
if (text === "") continue;
|
|
380
|
+
const lines = text.split(/\r?\n/);
|
|
381
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
382
|
+
const match = COMMENT_RE.exec(lines[i]);
|
|
383
|
+
if (match === null) continue;
|
|
384
|
+
total += 1;
|
|
385
|
+
if (kept.length >= MAX_COMMENTS) continue;
|
|
386
|
+
const body = match[2].trim().slice(0, MAX_COMMENT_LINE);
|
|
387
|
+
kept.push(`${rel}:${i + 1} ${match[1]} ${body}`.trimEnd());
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { kept, total, bounded, skippedForSize: skipped };
|
|
391
|
+
}
|
|
392
|
+
__name(collectComments, "collectComments");
|
|
393
|
+
function hasTest(base, testNames) {
|
|
394
|
+
return testNames.has(`${base}.test`) || testNames.has(`${base}.spec`) || testNames.has(`test_${base}`) || testNames.has(`${base}_test`) || testNames.has(base);
|
|
395
|
+
}
|
|
396
|
+
__name(hasTest, "hasTest");
|
|
397
|
+
function collectUntested(files) {
|
|
398
|
+
const testNames = /* @__PURE__ */ new Set();
|
|
399
|
+
for (const rel of files) {
|
|
400
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
401
|
+
const stem = name.replace(/\.[^.]+$/, "");
|
|
402
|
+
if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) {
|
|
403
|
+
testNames.add(stem);
|
|
404
|
+
testNames.add(stem.replace(/\.(test|spec)$/i, ""));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const ceiling = MAX_UNTESTED * SCAN_CEILING_FACTOR;
|
|
408
|
+
const kept = [];
|
|
409
|
+
let total = 0;
|
|
410
|
+
let bounded = false;
|
|
411
|
+
for (const rel of files) {
|
|
412
|
+
const dot = rel.lastIndexOf(".");
|
|
413
|
+
if (dot < 0 || !SOURCE_EXT.has(rel.slice(dot))) continue;
|
|
414
|
+
if (/(^|[./_-])(test|spec)([./_-]|$)/i.test(rel)) continue;
|
|
415
|
+
const stem = rel.slice(rel.lastIndexOf("/") + 1).replace(/\.[^.]+$/, "");
|
|
416
|
+
if (/^(index|main|types|constants)$/i.test(stem)) continue;
|
|
417
|
+
if (hasTest(stem, testNames)) continue;
|
|
418
|
+
if (total >= ceiling) {
|
|
419
|
+
bounded = true;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
total += 1;
|
|
423
|
+
if (kept.length < MAX_UNTESTED) kept.push(rel);
|
|
424
|
+
}
|
|
425
|
+
return { kept, total, bounded, skippedForSize: 0 };
|
|
426
|
+
}
|
|
427
|
+
__name(collectUntested, "collectUntested");
|
|
428
|
+
function sectionHeader(title, total, kept, options = {}) {
|
|
429
|
+
const bound = options.bounded === true ? "+" : "";
|
|
430
|
+
const skipped = options.skippedForSize ?? 0;
|
|
431
|
+
const note = skipped > 0 ? ` (${skipped} file(s) too large to read)` : "";
|
|
432
|
+
const counts = kept < total || options.bounded === true ? `(${total}${bound} found, showing ${kept})` : `(${total})`;
|
|
433
|
+
return `### ${title} ${counts}${note}`;
|
|
434
|
+
}
|
|
435
|
+
__name(sectionHeader, "sectionHeader");
|
|
436
|
+
function fileHeader(name, text, limit) {
|
|
437
|
+
if (text.length < limit) return `### ${name}`;
|
|
438
|
+
return `### ${name} (clipped to first ${Math.round(limit / 1e3)} KB)`;
|
|
439
|
+
}
|
|
440
|
+
__name(fileHeader, "fileHeader");
|
|
441
|
+
function assemble(sections, walkTruncated) {
|
|
442
|
+
const parts = walkTruncated ? sections.concat(
|
|
443
|
+
"[walk truncated \u2014 this workspace is deeper or larger than one scan walks; files below the depth or count limit were never examined]"
|
|
444
|
+
) : sections;
|
|
445
|
+
const joined = parts.join("\n\n");
|
|
446
|
+
if (joined.length <= DIGEST_BYTE_CAP) {
|
|
447
|
+
return { digest: joined, truncated: walkTruncated };
|
|
448
|
+
}
|
|
449
|
+
const marker = "\n\n[digest truncated \u2014 the workspace is larger than one scan can carry]";
|
|
450
|
+
return { digest: joined.slice(0, DIGEST_BYTE_CAP - marker.length) + marker, truncated: true };
|
|
451
|
+
}
|
|
452
|
+
__name(assemble, "assemble");
|
|
453
|
+
function buildDigest(root) {
|
|
454
|
+
const { files, truncated } = walk(root);
|
|
455
|
+
const sections = [];
|
|
456
|
+
let sectionTruncated = false;
|
|
457
|
+
const tree = files.slice(0, MAX_TREE_ENTRIES);
|
|
458
|
+
if (tree.length > 0) {
|
|
459
|
+
if (tree.length < files.length) sectionTruncated = true;
|
|
460
|
+
sections.push(`${sectionHeader("Files", files.length, tree.length)}
|
|
461
|
+
${tree.join("\n")}`);
|
|
462
|
+
}
|
|
463
|
+
const readmeName = files.find((f) => /^readme(\.md|\.txt)?$/i.test(f));
|
|
464
|
+
if (readmeName !== void 0) {
|
|
465
|
+
const raw = readText(join2(root, readmeName), README_BYTES);
|
|
466
|
+
const text = raw.trim();
|
|
467
|
+
if (text !== "") {
|
|
468
|
+
if (raw.length >= README_BYTES) sectionTruncated = true;
|
|
469
|
+
sections.push(`${fileHeader(readmeName, raw, README_BYTES)}
|
|
470
|
+
${text}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const manifest = files.find((f) => f === "package.json");
|
|
474
|
+
if (manifest !== void 0) {
|
|
475
|
+
const raw = readText(join2(root, manifest), MANIFEST_BYTES);
|
|
476
|
+
const text = raw.trim();
|
|
477
|
+
if (text !== "") {
|
|
478
|
+
if (raw.length >= MANIFEST_BYTES) sectionTruncated = true;
|
|
479
|
+
sections.push(`${fileHeader("package.json", raw, MANIFEST_BYTES)}
|
|
480
|
+
${text}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const comments = collectComments(root, files);
|
|
484
|
+
if (comments.kept.length > 0 || comments.skippedForSize > 0) {
|
|
485
|
+
if (comments.kept.length < comments.total || comments.bounded || comments.skippedForSize > 0) sectionTruncated = true;
|
|
486
|
+
sections.push(
|
|
487
|
+
sectionHeader(
|
|
488
|
+
"Unresolved comments (TODO/FIXME/HACK)",
|
|
489
|
+
comments.total,
|
|
490
|
+
comments.kept.length,
|
|
491
|
+
{ bounded: comments.bounded, skippedForSize: comments.skippedForSize }
|
|
492
|
+
) + (comments.kept.length > 0 ? "\n" + comments.kept.join("\n") : "")
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const untested = collectUntested(files);
|
|
496
|
+
if (untested.kept.length > 0) {
|
|
497
|
+
if (untested.kept.length < untested.total || untested.bounded) sectionTruncated = true;
|
|
498
|
+
sections.push(
|
|
499
|
+
sectionHeader(
|
|
500
|
+
"Untested modules (name-based hint, not a coverage run)",
|
|
501
|
+
untested.total,
|
|
502
|
+
untested.kept.length,
|
|
503
|
+
{ bounded: untested.bounded }
|
|
504
|
+
) + "\n" + untested.kept.join("\n")
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return assemble(sections, truncated || sectionTruncated);
|
|
508
|
+
}
|
|
509
|
+
__name(buildDigest, "buildDigest");
|
|
510
|
+
|
|
511
|
+
// src/suggest.ts
|
|
512
|
+
function unfence(raw) {
|
|
513
|
+
const open = /```[ \t]*[A-Za-z0-9_-]*[ \t]*\r?\n?/.exec(raw);
|
|
514
|
+
if (open === null) return raw;
|
|
515
|
+
const lead = raw.slice(0, open.index);
|
|
516
|
+
if (lead.includes("[") || lead.includes("{")) return raw;
|
|
517
|
+
const body = raw.slice(open.index + open[0].length);
|
|
518
|
+
const close = body.lastIndexOf("```");
|
|
519
|
+
return close === -1 ? raw : body.slice(0, close);
|
|
520
|
+
}
|
|
521
|
+
__name(unfence, "unfence");
|
|
522
|
+
function parseSuggestions(raw) {
|
|
523
|
+
let parsed;
|
|
524
|
+
try {
|
|
525
|
+
parsed = JSON.parse(unfence(raw));
|
|
526
|
+
} catch (cause) {
|
|
527
|
+
return { ok: false, error: `the scan wrote invalid JSON: ${cause instanceof Error ? cause.message : String(cause)}` };
|
|
528
|
+
}
|
|
529
|
+
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.suggestions) ? parsed.suggestions : void 0;
|
|
530
|
+
if (list === void 0) {
|
|
531
|
+
return { ok: false, error: "the scan did not write a list of suggestions" };
|
|
532
|
+
}
|
|
533
|
+
const suggestions = [];
|
|
534
|
+
const seen = /* @__PURE__ */ new Set();
|
|
535
|
+
for (const entry of list) {
|
|
536
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
537
|
+
const row = entry;
|
|
538
|
+
const title = typeof row.title === "string" ? row.title.trim() : "";
|
|
539
|
+
if (title.length === 0) continue;
|
|
540
|
+
const stored = title.slice(0, MAX_TEXT);
|
|
541
|
+
const key = stored.toLowerCase();
|
|
542
|
+
if (seen.has(key)) continue;
|
|
543
|
+
seen.add(key);
|
|
544
|
+
const evidence = typeof row.evidence === "string" ? row.evidence.trim().slice(0, MAX_LABEL) : "";
|
|
545
|
+
suggestions.push({
|
|
546
|
+
title: stored,
|
|
547
|
+
rationale: typeof row.rationale === "string" ? row.rationale.trim().slice(0, MAX_DESC) : "",
|
|
548
|
+
priority: toPriority(row.priority),
|
|
549
|
+
// Absent optional fields are ABSENT KEYS, never '', matching TodoItem.
|
|
550
|
+
...evidence.length > 0 ? { evidence } : {}
|
|
551
|
+
});
|
|
552
|
+
if (suggestions.length >= MAX_SUGGESTIONS) break;
|
|
553
|
+
}
|
|
554
|
+
return { ok: true, suggestions };
|
|
555
|
+
}
|
|
556
|
+
__name(parseSuggestions, "parseSuggestions");
|
|
557
|
+
|
|
216
558
|
// src/index.ts
|
|
217
559
|
var todoItemSchema = z.object({
|
|
218
560
|
id: z.string().min(1),
|
|
@@ -223,6 +565,7 @@ var todoItemSchema = z.object({
|
|
|
223
565
|
release: z.string().max(MAX_LABEL).optional(),
|
|
224
566
|
sprint: z.string().max(MAX_LABEL).optional(),
|
|
225
567
|
dueDate: z.string().optional(),
|
|
568
|
+
sessionId: z.string().optional(),
|
|
226
569
|
createdAt: z.number(),
|
|
227
570
|
completedAt: z.number().optional(),
|
|
228
571
|
archivedAt: z.number().optional()
|
|
@@ -236,7 +579,7 @@ var todoDomainSpec = {
|
|
|
236
579
|
name: "dsh_todo",
|
|
237
580
|
version: 2
|
|
238
581
|
};
|
|
239
|
-
var _replace_dec, _list_dec, _a, _init, _b;
|
|
582
|
+
var _readSuggestions_dec, _scanDigest_dec, _replace_dec, _list_dec, _a, _init, _b;
|
|
240
583
|
var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
241
584
|
/**
|
|
242
585
|
* @param ctx - host context carrying the workspace registry.
|
|
@@ -263,19 +606,19 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
|
263
606
|
* Runs once per harness home; a workspace whose db already has content is
|
|
264
607
|
* left untouched, so a half-finished migration never clobbers newer data.
|
|
265
608
|
*/
|
|
266
|
-
async [(_a = Service.init, _list_dec = [Remote], _replace_dec = [Remote], _a)]() {
|
|
609
|
+
async [(_a = Service.init, _list_dec = [Remote], _replace_dec = [Remote], _scanDigest_dec = [Remote], _readSuggestions_dec = [Remote], _a)]() {
|
|
267
610
|
this.ctx.effect(() => () => this.close(), "dsh-todo: close workspace databases");
|
|
268
611
|
const home = process.env.DSH_HOME;
|
|
269
612
|
if (!home) return;
|
|
270
|
-
const legacyPath =
|
|
271
|
-
const registryPath =
|
|
613
|
+
const legacyPath = join3(home, "storages", "dsh_todo.json");
|
|
614
|
+
const registryPath = join3(home, "storages", "workspace.json");
|
|
272
615
|
try {
|
|
273
616
|
if (!existsSync(legacyPath)) return;
|
|
274
|
-
const legacy = JSON.parse(
|
|
617
|
+
const legacy = JSON.parse(readFileSync2(legacyPath, "utf8"));
|
|
275
618
|
const records = legacy?.tables?.workspaces ?? {};
|
|
276
619
|
let mapping = {};
|
|
277
620
|
if (existsSync(registryPath)) {
|
|
278
|
-
mapping = JSON.parse(
|
|
621
|
+
mapping = JSON.parse(readFileSync2(registryPath, "utf8"))?.tables?.workspaces ?? {};
|
|
279
622
|
}
|
|
280
623
|
let importedItems = 0;
|
|
281
624
|
let importedWorkspaces = 0;
|
|
@@ -363,6 +706,69 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
|
363
706
|
return { ok: true, list: this.readList(db) };
|
|
364
707
|
});
|
|
365
708
|
}
|
|
709
|
+
async scanDigest(request) {
|
|
710
|
+
return buildDigest(this.workspaceDir(request?.workspaceId));
|
|
711
|
+
}
|
|
712
|
+
async readSuggestions(request) {
|
|
713
|
+
const dir = this.workspaceDir(request?.workspaceId);
|
|
714
|
+
const runId = request?.runId;
|
|
715
|
+
if (typeof runId !== "string" || !/^[a-z0-9]+$/.test(runId)) {
|
|
716
|
+
throw new Error("dsh-todo: runId must be a non-empty lowercase alphanumeric string");
|
|
717
|
+
}
|
|
718
|
+
const path = join3(dir, ...suggestionsFileFor(runId).split("/"));
|
|
719
|
+
this.sweepOrphanResults(dir, runId);
|
|
720
|
+
let raw;
|
|
721
|
+
try {
|
|
722
|
+
raw = readFileSync2(path, "utf8");
|
|
723
|
+
} catch (err) {
|
|
724
|
+
const code = err.code;
|
|
725
|
+
if (code === "ENOENT") return { status: "pending" };
|
|
726
|
+
return { status: "error", error: `dsh-todo: the scan result could not be read: ${code ?? String(err)}` };
|
|
727
|
+
}
|
|
728
|
+
const parsed = parseSuggestions(raw);
|
|
729
|
+
try {
|
|
730
|
+
unlinkSync(path);
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
if (!parsed.ok) return { status: "error", error: parsed.error };
|
|
734
|
+
return { status: "ready", suggestions: parsed.suggestions };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Delete every result file that is not the run currently reading.
|
|
738
|
+
*
|
|
739
|
+
* Per-run paths fix the cross-run bleed but introduce their own litter: a
|
|
740
|
+
* scan whose modal was closed still finishes and writes, and nobody ever
|
|
741
|
+
* reads that file. One orphan per abandoned scan accumulates in `.dsh`
|
|
742
|
+
* indefinitely, and abandoning a scan is the ordinary case, not the rare one.
|
|
743
|
+
*
|
|
744
|
+
* Sweeping on every poll — rather than at scan start — is deliberate and
|
|
745
|
+
* cheaper than it looks: a scan already polls this endpoint every 1.5s, one
|
|
746
|
+
* `readdir` of `.dsh` is trivially small beside the digest walk that preceded
|
|
747
|
+
* it, and it means a run started from a build with no sweep at all is still
|
|
748
|
+
* cleaned up by the next one. It also collects the legacy fixed-path file, so
|
|
749
|
+
* an upgrade needs no migration step.
|
|
750
|
+
*
|
|
751
|
+
* The regex is anchored on both ends: `.dsh` holds `todo.db` and whatever
|
|
752
|
+
* else the harness keeps there, and a sweep that guessed wider would delete a
|
|
753
|
+
* neighbour's data. Failure is swallowed throughout — this is housekeeping,
|
|
754
|
+
* and a scan that produced an answer must not fail over tidying.
|
|
755
|
+
*
|
|
756
|
+
* @param dir - the resolved workspace directory.
|
|
757
|
+
* @param runId - the run whose file must SURVIVE.
|
|
758
|
+
*/
|
|
759
|
+
sweepOrphanResults(dir, runId) {
|
|
760
|
+
const keep = suggestionsFileFor(runId).split("/").pop();
|
|
761
|
+
try {
|
|
762
|
+
for (const name of readdirSync2(join3(dir, SUGGESTIONS_DIR))) {
|
|
763
|
+
if (name === keep || !SUGGESTIONS_FILE_RE.test(name)) continue;
|
|
764
|
+
try {
|
|
765
|
+
unlinkSync(join3(dir, SUGGESTIONS_DIR, name));
|
|
766
|
+
} catch {
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
}
|
|
366
772
|
/** Queue one whole read/compare/write behind this workspace's prior write. */
|
|
367
773
|
async enqueue(workspaceId, run) {
|
|
368
774
|
const prior = this.tails.get(workspaceId) ?? Promise.resolve();
|
|
@@ -414,6 +820,8 @@ var _TodoService = class _TodoService extends (_b = TypertRemoteService) {
|
|
|
414
820
|
_init = __decoratorStart(_b);
|
|
415
821
|
__decorateElement(_init, 1, "list", _list_dec, _TodoService);
|
|
416
822
|
__decorateElement(_init, 1, "replace", _replace_dec, _TodoService);
|
|
823
|
+
__decorateElement(_init, 1, "scanDigest", _scanDigest_dec, _TodoService);
|
|
824
|
+
__decorateElement(_init, 1, "readSuggestions", _readSuggestions_dec, _TodoService);
|
|
417
825
|
__decoratorMetadata(_init, _TodoService);
|
|
418
826
|
__name(_TodoService, "TodoService");
|
|
419
827
|
// Per-fiber service grants: the workspace registry property is only readable
|
|
@@ -439,6 +847,7 @@ function sanitizeItems(value) {
|
|
|
439
847
|
const release = normalizeLabel(e.release);
|
|
440
848
|
const sprint = normalizeLabel(e.sprint);
|
|
441
849
|
const dueDate = normalizeDueDate(e.dueDate);
|
|
850
|
+
const sessionId = typeof e.sessionId === "string" && e.sessionId.length > 0 ? e.sessionId.slice(0, MAX_LABEL) : void 0;
|
|
442
851
|
const completedAt = typeof e.completedAt === "number" ? e.completedAt : void 0;
|
|
443
852
|
const archivedAt = typeof e.archivedAt === "number" ? e.archivedAt : void 0;
|
|
444
853
|
out.push({
|
|
@@ -450,6 +859,7 @@ function sanitizeItems(value) {
|
|
|
450
859
|
...release !== void 0 ? { release } : {},
|
|
451
860
|
...sprint !== void 0 ? { sprint } : {},
|
|
452
861
|
...dueDate !== void 0 ? { dueDate } : {},
|
|
862
|
+
...sessionId !== void 0 ? { sessionId } : {},
|
|
453
863
|
createdAt: typeof e.createdAt === "number" ? e.createdAt : 0,
|
|
454
864
|
// completedAt is meaningless on an unfinished item; drop it rather than store a lie.
|
|
455
865
|
...done && completedAt !== void 0 ? { completedAt } : {},
|
package/lib/launch.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/launch.ts
|
|
2
|
+
function composePrompt(item) {
|
|
3
|
+
const parts = [`# ${item.title}`];
|
|
4
|
+
const description = item.description?.trim();
|
|
5
|
+
if (description) parts.push(description);
|
|
6
|
+
const context = [];
|
|
7
|
+
if (item.priority) context.push(`Priority: ${item.priority.toUpperCase()}`);
|
|
8
|
+
if (item.release) context.push(`Release: ${item.release}`);
|
|
9
|
+
if (item.sprint) context.push(`Sprint: ${item.sprint}`);
|
|
10
|
+
if (item.dueDate) context.push(`Due: ${item.dueDate}`);
|
|
11
|
+
if (context.length > 0) parts.push(context.join(" \xB7 "));
|
|
12
|
+
return parts.join("\n\n");
|
|
13
|
+
}
|
|
14
|
+
var MAX_SESSION_TITLE = 80;
|
|
15
|
+
function sessionTitleFor(item) {
|
|
16
|
+
const normalized = item.title.replace(/\s+/g, " ").trim();
|
|
17
|
+
if (normalized.length === 0) return void 0;
|
|
18
|
+
if (normalized.length <= MAX_SESSION_TITLE) return normalized;
|
|
19
|
+
const clipped = normalized.slice(0, MAX_SESSION_TITLE);
|
|
20
|
+
const lastSpace = clipped.lastIndexOf(" ");
|
|
21
|
+
return `${(lastSpace > MAX_SESSION_TITLE - 20 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}\u2026`;
|
|
22
|
+
}
|
|
23
|
+
function flattenModels(groups) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const group of groups) {
|
|
26
|
+
const provider = group.id;
|
|
27
|
+
if (provider === void 0) continue;
|
|
28
|
+
const heading = group.name ?? "";
|
|
29
|
+
for (const model of group.models ?? []) {
|
|
30
|
+
const id = model.id;
|
|
31
|
+
if (id === void 0) continue;
|
|
32
|
+
out.push({
|
|
33
|
+
provider,
|
|
34
|
+
model: id,
|
|
35
|
+
label: model.name ?? id,
|
|
36
|
+
group: heading
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
var BUILT_IN_PRESET_NAME_KEYS = {
|
|
43
|
+
standard: "presetStandardName",
|
|
44
|
+
ptc: "presetPtcName",
|
|
45
|
+
minimal: "presetMinimalName",
|
|
46
|
+
cordis: "presetCordisName"
|
|
47
|
+
};
|
|
48
|
+
function presetOptions(presets, t) {
|
|
49
|
+
const healthy = presets.filter((preset) => preset.broken === void 0);
|
|
50
|
+
const options = healthy.map((preset) => {
|
|
51
|
+
const key = preset.trust === "system" ? BUILT_IN_PRESET_NAME_KEYS[preset.id] : void 0;
|
|
52
|
+
const translated = key !== void 0 && t !== void 0 ? t(key) : void 0;
|
|
53
|
+
const localized = translated !== void 0 && translated !== key ? translated : void 0;
|
|
54
|
+
return {
|
|
55
|
+
id: preset.id,
|
|
56
|
+
label: localized ?? preset.label ?? preset.name ?? preset.title ?? preset.id
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
const defaultId = healthy.find((preset) => preset.isDefault)?.id ?? healthy[0]?.id;
|
|
60
|
+
return { options, defaultId };
|
|
61
|
+
}
|
|
62
|
+
async function launchSession(ctx, request) {
|
|
63
|
+
const { sessionId, presetId, model, prompt, title } = request;
|
|
64
|
+
if (presetId !== void 0 && ctx.remote.agentPresets !== void 0) {
|
|
65
|
+
let applied;
|
|
66
|
+
try {
|
|
67
|
+
applied = await ctx.remote.agentPresets.select(sessionId, presetId);
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
throw new Error(`could not set mode: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!applied.ok) {
|
|
72
|
+
throw new Error(`could not set mode: ${applied.error.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (model !== void 0 && ctx.modelDirectories !== void 0) {
|
|
76
|
+
const directory = ctx.modelDirectories.directoryFor(sessionId);
|
|
77
|
+
if (directory !== void 0) await directory.select(model);
|
|
78
|
+
}
|
|
79
|
+
const binding = ctx.sessions.binding(sessionId);
|
|
80
|
+
if (binding === void 0) {
|
|
81
|
+
throw new Error("the new session is not addressable yet");
|
|
82
|
+
}
|
|
83
|
+
const sent = await binding.session.prompt([{ type: "text", text: prompt }], "queue");
|
|
84
|
+
if (!sent.ok) {
|
|
85
|
+
throw new Error(`could not send the prompt: ${sent.error?.message ?? "unknown error"}`);
|
|
86
|
+
}
|
|
87
|
+
if (title !== void 0 && typeof binding.session.rename === "function") {
|
|
88
|
+
try {
|
|
89
|
+
await binding.session.rename(title);
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
ctx.sessions.open(sessionId);
|
|
94
|
+
return sessionId;
|
|
95
|
+
}
|
|
96
|
+
async function discardSession(ctx, sessionId) {
|
|
97
|
+
try {
|
|
98
|
+
await ctx.uiWorkspace?.archiveSession(sessionId);
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export {
|
|
103
|
+
composePrompt,
|
|
104
|
+
discardSession,
|
|
105
|
+
flattenModels,
|
|
106
|
+
launchSession,
|
|
107
|
+
presetOptions,
|
|
108
|
+
sessionTitleFor
|
|
109
|
+
};
|