@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/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 join2, resolve as resolve2 } from "node:path";
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 = join2(home, "storages", "dsh_todo.json");
271
- const registryPath = join2(home, "storages", "workspace.json");
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(readFileSync(legacyPath, "utf8"));
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(readFileSync(registryPath, "utf8"))?.tables?.workspaces ?? {};
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
+ };