portable-agent-layer 0.71.0 → 0.73.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/package.json +10 -7
- package/src/cli/migrate.ts +1 -1
- package/src/cli/server.ts +7 -0
- package/src/cli/skill.ts +1 -1
- package/src/hooks/CompactRecover.ts +28 -86
- package/src/hooks/LedgerUnapplied.ts +3 -28
- package/src/hooks/LoadContext.ts +33 -60
- package/src/hooks/SecurityValidator.ts +16 -109
- package/src/hooks/handlers/agenda.ts +195 -7
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/agenda-store.ts +2 -0
- package/src/hooks/lib/capture-store.ts +103 -0
- package/src/hooks/lib/compact-recall.ts +89 -0
- package/src/hooks/lib/failure-principle.ts +98 -0
- package/src/hooks/lib/goal-links.ts +83 -0
- package/src/hooks/lib/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/models.ts +1 -1
- package/src/hooks/lib/projects.ts +5 -0
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/session-context.ts +74 -0
- package/src/hooks/lib/settings.ts +2 -0
- package/src/hooks/lib/telos-goals.ts +8 -2
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/agent/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +29 -77
- package/src/tools/agent/project.ts +34 -153
- package/src/tools/agent/relationship-note.ts +27 -46
- package/src/tools/agent/synthesize.ts +1 -1
- package/src/tools/agent/thread.ts +43 -123
- package/src/tools/control-room/attention.ts +146 -0
- package/src/tools/control-room/data.ts +78 -7
- package/src/tools/control-room/detail.ts +158 -0
- package/src/tools/control-room/matrix.ts +92 -20
- package/src/tools/control-room/prefs.ts +96 -0
- package/src/tools/control-room/server-config.ts +8 -0
- package/src/tools/control-room/server.ts +196 -11
- package/src/tools/control-room/snooze.ts +65 -0
- package/src/tools/control-room/static.ts +68 -0
- package/src/tools/control-room/ui/app.tsx +196 -50
- package/src/tools/control-room/ui/attention-bell.tsx +118 -0
- package/src/tools/control-room/ui/components/README.md +18 -0
- package/src/tools/control-room/ui/components/badge.tsx +38 -0
- package/src/tools/control-room/ui/components/button.tsx +43 -0
- package/src/tools/control-room/ui/components/card.tsx +45 -0
- package/src/tools/control-room/ui/components/input.tsx +24 -0
- package/src/tools/control-room/ui/components/label.tsx +18 -0
- package/src/tools/control-room/ui/components/popover.tsx +37 -0
- package/src/tools/control-room/ui/components/separator.tsx +25 -0
- package/src/tools/control-room/ui/components/skeleton.tsx +14 -0
- package/src/tools/control-room/ui/components/switch.tsx +27 -0
- package/src/tools/control-room/ui/components/table.tsx +60 -0
- package/src/tools/control-room/ui/components/toggle-group.tsx +38 -0
- package/src/tools/control-room/ui/dist/assets/index-BoQjFpzV.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +19 -0
- package/src/tools/control-room/ui/format.ts +0 -12
- package/src/tools/control-room/ui/frame.tsx +125 -0
- package/src/tools/control-room/ui/index.html +2 -2
- package/src/tools/control-room/ui/lib/api.ts +27 -0
- package/src/tools/control-room/ui/lib/cn.ts +6 -0
- package/src/tools/control-room/ui/lib/write.ts +43 -0
- package/src/tools/control-room/ui/package.json +28 -0
- package/src/tools/control-room/ui/parts.tsx +235 -0
- package/src/tools/control-room/ui/screens/log.tsx +274 -0
- package/src/tools/control-room/ui/screens/project-detail.tsx +302 -0
- package/src/tools/control-room/ui/screens/projects.tsx +128 -0
- package/src/tools/control-room/ui/screens/settings.tsx +205 -0
- package/src/tools/control-room/ui/screens/today.tsx +391 -0
- package/src/tools/control-room/ui/theme.css +127 -0
- package/src/tools/control-room/ui/vite.config.ts +36 -0
- package/src/tools/control-room/writes.ts +106 -0
- package/src/tools/ledger/outcomes.ts +9 -0
- package/src/tools/ledger/query.ts +2 -0
- package/src/tools/ledger/view.ts +34 -10
- package/src/tools/lib/algorithm-reflect.ts +84 -0
- package/src/tools/lib/analyze-report.ts +120 -0
- package/src/tools/lib/handoff-note.ts +102 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +212 -0
- package/src/tools/lib/relationship-reflect.ts +402 -0
- package/src/tools/lib/self-model.ts +499 -0
- package/src/tools/lib/session-usage.ts +216 -0
- package/src/tools/lib/skill-doctor.ts +457 -0
- package/src/tools/lib/thread.ts +119 -0
- package/src/tools/lib/token-report.ts +173 -0
- package/src/tools/lib/transcript-usage.ts +42 -0
- package/src/tools/lib/usage-buckets.ts +329 -0
- package/src/tools/relationship-reflect.ts +48 -412
- package/src/tools/self-model.ts +76 -558
- package/src/tools/session-summary.ts +8 -215
- package/src/tools/skill-doctor.ts +9 -444
- package/src/tools/token-cost.ts +18 -428
- package/src/tools/control-room/ui/agenda.tsx +0 -43
- package/src/tools/control-room/ui/agents.tsx +0 -67
- package/src/tools/control-room/ui/app.css +0 -857
- package/src/tools/control-room/ui/board.tsx +0 -82
- package/src/tools/control-room/ui/handoffs.tsx +0 -37
- package/src/tools/control-room/ui/ledger.tsx +0 -136
- package/src/tools/control-room/ui/matrix.tsx +0 -117
- package/src/tools/control-room/ui/panel.tsx +0 -60
- package/src/tools/control-room/ui/signal.tsx +0 -161
|
@@ -40,6 +40,21 @@ import {
|
|
|
40
40
|
writeProject,
|
|
41
41
|
} from "../../hooks/lib/projects";
|
|
42
42
|
import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
|
|
43
|
+
import {
|
|
44
|
+
archiveLine,
|
|
45
|
+
completeIsc,
|
|
46
|
+
encodeIscText,
|
|
47
|
+
ISC_BOX,
|
|
48
|
+
type IscMove,
|
|
49
|
+
type IscSections,
|
|
50
|
+
iscTitle,
|
|
51
|
+
nextIscId,
|
|
52
|
+
parseIscs,
|
|
53
|
+
removeIscLine,
|
|
54
|
+
reopenIsc,
|
|
55
|
+
selectIscs,
|
|
56
|
+
taskSlug,
|
|
57
|
+
} from "../lib/project-isc";
|
|
43
58
|
|
|
44
59
|
function now(): string {
|
|
45
60
|
return new Date().toISOString();
|
|
@@ -400,130 +415,13 @@ function cmdRm(args: string[]): void {
|
|
|
400
415
|
// Three states, not two: a retired ISC is one that stopped being valid, which the
|
|
401
416
|
// record must not report as completed work. The box character is the storage form
|
|
402
417
|
// and the id stays in it, so a retired line keeps reserving its id in nextIscId.
|
|
403
|
-
type IscStatus = "open" | "done" | "retired";
|
|
404
|
-
|
|
405
|
-
const ISC_BOX: Record<IscStatus, string> = {
|
|
406
|
-
open: "[ ]",
|
|
407
|
-
done: "[x]",
|
|
408
|
-
retired: "[~]",
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
function statusFromBox(box: string): IscStatus {
|
|
412
|
-
if (box.toLowerCase() === "x") return "done";
|
|
413
|
-
if (box === "~") return "retired";
|
|
414
|
-
return "open";
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export interface Isc {
|
|
418
|
-
id: number;
|
|
419
|
-
text: string;
|
|
420
|
-
status: IscStatus;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* An ISC is one markdown line, so a newline in its text would end the record
|
|
425
|
-
* and strand every paragraph after it as unparseable debris. Backslashes are
|
|
426
|
-
* escaped first so that decoding a literal "\n" in a regex cannot be mistaken
|
|
427
|
-
* for the separator.
|
|
428
|
-
*/
|
|
429
|
-
function encodeIscText(text: string): string {
|
|
430
|
-
return text
|
|
431
|
-
.replaceAll("\\", "\\\\")
|
|
432
|
-
.replaceAll("\r\n", "\n")
|
|
433
|
-
.replaceAll("\r", "\n")
|
|
434
|
-
.replaceAll("\n", "\\n");
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
|
|
438
|
-
|
|
439
|
-
function decodeIscText(stored: string): string {
|
|
440
|
-
return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
export function parseIscs(criteria: string): Isc[] {
|
|
444
|
-
const out: Isc[] = [];
|
|
445
|
-
for (const line of criteria.split("\n")) {
|
|
446
|
-
const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
447
|
-
if (m)
|
|
448
|
-
out.push({
|
|
449
|
-
id: Number(m[2]),
|
|
450
|
-
text: decodeIscText(m[3].trim()),
|
|
451
|
-
status: statusFromBox(m[1]),
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
return out;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// Collapse a full ISC line to a glanceable title for resume: cut at the first
|
|
458
|
-
// clause boundary, then hard-cap length. Full text stays reachable via show-isc.
|
|
459
|
-
function iscTitle(text: string): string {
|
|
460
|
-
const boundary = text.search(/; | — | \(|\. /);
|
|
461
|
-
const clause = (boundary > 0 ? text.slice(0, boundary) : text).trim();
|
|
462
|
-
return clause.length > 80 ? `${clause.slice(0, 79).trimEnd()}…` : clause;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Scans Criteria AND Changelog so an archived id can never be handed out again.
|
|
466
|
-
function nextIscId(p: ProjectProgress): number {
|
|
467
|
-
const ids = [...parseIscs(p.criteria ?? ""), ...parseIscs(p.changelog ?? "")].map(
|
|
468
|
-
(i) => i.id
|
|
469
|
-
);
|
|
470
|
-
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function removeIscLine(
|
|
474
|
-
section: string,
|
|
475
|
-
id: number
|
|
476
|
-
): { line: string | null; rest: string } {
|
|
477
|
-
const lines = section.split("\n");
|
|
478
|
-
const idx = lines.findIndex((l) =>
|
|
479
|
-
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
|
|
480
|
-
);
|
|
481
|
-
if (idx === -1) return { line: null, rest: section };
|
|
482
|
-
const [line] = lines.splice(idx, 1);
|
|
483
|
-
return {
|
|
484
|
-
line,
|
|
485
|
-
rest: lines
|
|
486
|
-
.join("\n")
|
|
487
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
488
|
-
.trim(),
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Drops any "### Archived <date>" heading whose block has no content left —
|
|
493
|
-
// e.g. after every ISC filed under that date has been reopened.
|
|
494
|
-
function dropEmptyArchiveHeadings(changelog: string): string {
|
|
495
|
-
const lines = changelog.split("\n");
|
|
496
|
-
const blockHasContent = (headingIdx: number): boolean => {
|
|
497
|
-
for (let j = headingIdx + 1; j < lines.length && !lines[j].startsWith("### "); j++) {
|
|
498
|
-
if (lines[j].trim() !== "") return true;
|
|
499
|
-
}
|
|
500
|
-
return false;
|
|
501
|
-
};
|
|
502
|
-
return lines
|
|
503
|
-
.filter((l, i) => !(/^### Archived /.test(l) && !blockHasContent(i)))
|
|
504
|
-
.join("\n")
|
|
505
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
506
|
-
.trim();
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function archiveLine(
|
|
510
|
-
changelog: string | undefined,
|
|
511
|
-
doneLine: string,
|
|
512
|
-
kind: "Archived" | "Retired" = "Archived"
|
|
513
|
-
): string {
|
|
514
|
-
const heading = `### ${kind} ${new Date().toISOString().slice(0, 10)}`;
|
|
515
|
-
const base = (changelog ?? "").trim();
|
|
516
|
-
if (base.includes(heading)) return `${base}\n${doneLine}`;
|
|
517
|
-
return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
418
|
function cmdAddIsc(args: string[]): void {
|
|
521
419
|
const name = args[0] ?? fail("Usage: add-isc <name> <title>");
|
|
522
420
|
const title = args.slice(1).join(" ").trim();
|
|
523
421
|
if (!title) fail("Usage: add-isc <name> <title>");
|
|
524
422
|
const p = requireProject(name);
|
|
525
423
|
const current = p.criteria ?? "";
|
|
526
|
-
const id = nextIscId(p);
|
|
424
|
+
const id = nextIscId(current, p.changelog ?? "");
|
|
527
425
|
const newLine = `- [ ] ISC-${id}: ${encodeIscText(title)}`;
|
|
528
426
|
p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
|
|
529
427
|
p.updated = now();
|
|
@@ -545,17 +443,25 @@ function cmdCompleteIsc(args: string[]): void {
|
|
|
545
443
|
const id = Number(args[1] ?? fail("Usage: complete-isc <name> <id>"));
|
|
546
444
|
if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
|
|
547
445
|
const p = requireProject(name);
|
|
548
|
-
|
|
446
|
+
const move = completeIsc(sectionsOf(p), id);
|
|
447
|
+
if (!move.ok) fail(`${move.reason} in project "${name}"`);
|
|
448
|
+
if (move.already) {
|
|
549
449
|
ok({ checked: true, id, alreadyDone: true });
|
|
550
450
|
return;
|
|
551
451
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
452
|
+
applyIscMove(p, move);
|
|
453
|
+
ok({ checked: true, id, archived: true });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function sectionsOf(p: ProjectProgress): IscSections {
|
|
457
|
+
return { criteria: p.criteria ?? "", changelog: p.changelog ?? "" };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function applyIscMove(p: ProjectProgress, move: IscMove & { ok: true }): void {
|
|
461
|
+
p.criteria = move.criteria;
|
|
462
|
+
p.changelog = move.changelog;
|
|
556
463
|
p.updated = now();
|
|
557
464
|
writeProject(p);
|
|
558
|
-
ok({ checked: true, id, archived: true });
|
|
559
465
|
}
|
|
560
466
|
|
|
561
467
|
// Reopening pulls the line back out of the Changelog (or legacy Criteria) into
|
|
@@ -565,32 +471,16 @@ function cmdReopenIsc(args: string[]): void {
|
|
|
565
471
|
const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
|
|
566
472
|
if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
|
|
567
473
|
const p = requireProject(name);
|
|
568
|
-
|
|
474
|
+
const move = reopenIsc(sectionsOf(p), id);
|
|
475
|
+
if (!move.ok) fail(`${move.reason} in project "${name}"`);
|
|
476
|
+
if (move.already) {
|
|
569
477
|
ok({ checked: false, id, alreadyOpen: true });
|
|
570
478
|
return;
|
|
571
479
|
}
|
|
572
|
-
|
|
573
|
-
if (removed.line) {
|
|
574
|
-
p.changelog = dropEmptyArchiveHeadings(removed.rest);
|
|
575
|
-
} else {
|
|
576
|
-
removed = removeIscLine(p.criteria ?? "", id);
|
|
577
|
-
if (removed.line) p.criteria = removed.rest;
|
|
578
|
-
}
|
|
579
|
-
if (!removed.line) fail(`ISC-${id} not found in project "${name}"`);
|
|
580
|
-
const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
|
|
581
|
-
p.criteria = p.criteria ? `${p.criteria.trimEnd()}\n${openLine}` : openLine;
|
|
582
|
-
p.updated = now();
|
|
583
|
-
writeProject(p);
|
|
480
|
+
applyIscMove(p, move);
|
|
584
481
|
ok({ checked: false, id });
|
|
585
482
|
}
|
|
586
483
|
|
|
587
|
-
function selectIscs(open: Isc[], done: Isc[], retired: Isc[], flags: Set<string>): Isc[] {
|
|
588
|
-
if (flags.has("--all")) return [...open, ...done, ...retired];
|
|
589
|
-
if (flags.has("--closed")) return done;
|
|
590
|
-
if (flags.has("--retired")) return retired;
|
|
591
|
-
return open;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
484
|
function cmdListIsc(args: string[]): void {
|
|
595
485
|
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
596
486
|
const name =
|
|
@@ -721,15 +611,6 @@ function cmdPruneIsc(args: string[]): void {
|
|
|
721
611
|
|
|
722
612
|
// ── Task ISA (work/) ──────────────────────────────────────────────
|
|
723
613
|
|
|
724
|
-
function taskSlug(title: string): string {
|
|
725
|
-
const sanitized = title
|
|
726
|
-
.toLowerCase()
|
|
727
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
728
|
-
.replace(/^-+|-+$/g, "")
|
|
729
|
-
.slice(0, 40);
|
|
730
|
-
return `${sanitized}-${Date.now().toString(36)}`;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
614
|
function taskIsaPath(slug: string): string {
|
|
734
615
|
const dir = resolve(paths.work(), slug);
|
|
735
616
|
mkdirSync(dir, { recursive: true });
|
|
@@ -13,27 +13,17 @@
|
|
|
13
13
|
* Note types:
|
|
14
14
|
* --o Opinion/behavioral observation about the user (requires --confidence)
|
|
15
15
|
* --w World fact about the user's situation (objective, observable)
|
|
16
|
-
* --b Session diary — what
|
|
16
|
+
* --b Session diary — what the assistant did this session (first-person)
|
|
17
|
+
*
|
|
18
|
+
* Which flags make which notes is in lib/note-flags.ts.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { parseArgs } from "node:util";
|
|
20
22
|
import { appendNotes } from "../../hooks/lib/relationship";
|
|
21
23
|
import { emit } from "../lib/emit";
|
|
24
|
+
import { notesFromFlags } from "../lib/note-flags";
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
const { values } = parseArgs({
|
|
25
|
-
args: Bun.argv.slice(2),
|
|
26
|
-
options: {
|
|
27
|
-
o: { type: "string", multiple: true },
|
|
28
|
-
w: { type: "string", multiple: true },
|
|
29
|
-
b: { type: "string" },
|
|
30
|
-
confidence: { type: "string" },
|
|
31
|
-
help: { type: "boolean", short: "h" },
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
if (values.help) {
|
|
36
|
-
console.log(`
|
|
26
|
+
const HELP = `
|
|
37
27
|
RelationshipNote — Append W/O/Session entries to today's relationship log
|
|
38
28
|
|
|
39
29
|
Usage:
|
|
@@ -45,45 +35,36 @@ Flags:
|
|
|
45
35
|
--o TEXT Opinion/behavioral observation about the user
|
|
46
36
|
--confidence N Confidence for --o (0.0–1.0, default 0.75)
|
|
47
37
|
--w TEXT World fact about the user's situation
|
|
48
|
-
--b TEXT Session diary — what
|
|
38
|
+
--b TEXT Session diary — what the assistant did (first-person)
|
|
49
39
|
|
|
50
40
|
Multiple flags may be combined in one call. At least one of --o, --w, --b is required.
|
|
51
41
|
|
|
52
42
|
Output: appends to memory/relationship/YYYY-MM/YYYY-MM-DD.md
|
|
53
|
-
|
|
54
|
-
process.exit(0);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (!values.o && !values.w && !values.b) {
|
|
58
|
-
console.error("Required: at least one of --o, --w, --b");
|
|
59
|
-
process.exit(1);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const notes = [];
|
|
43
|
+
`;
|
|
63
44
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
45
|
+
if (import.meta.main) {
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
args: Bun.argv.slice(2),
|
|
48
|
+
options: {
|
|
49
|
+
o: { type: "string", multiple: true },
|
|
50
|
+
w: { type: "string", multiple: true },
|
|
51
|
+
b: { type: "string" },
|
|
52
|
+
confidence: { type: "string" },
|
|
53
|
+
help: { type: "boolean", short: "h" },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
74
56
|
|
|
75
|
-
if (values.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
57
|
+
if (values.help) {
|
|
58
|
+
console.log(HELP);
|
|
59
|
+
process.exit(0);
|
|
79
60
|
}
|
|
80
61
|
|
|
81
|
-
|
|
82
|
-
|
|
62
|
+
const result = notesFromFlags(values);
|
|
63
|
+
if ("error" in result) {
|
|
64
|
+
console.error(result.error);
|
|
65
|
+
process.exit(1);
|
|
83
66
|
}
|
|
84
67
|
|
|
85
|
-
const { file, written } = appendNotes(notes);
|
|
86
|
-
emit.receipt(file, { written, deduped: notes.length - written });
|
|
68
|
+
const { file, written } = appendNotes(result.notes);
|
|
69
|
+
emit.receipt(file, { written, deduped: result.notes.length - written });
|
|
87
70
|
}
|
|
88
|
-
|
|
89
|
-
if (import.meta.main) run();
|
|
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
17
17
|
import { resolve } from "node:path";
|
|
18
18
|
import { parseArgs } from "node:util";
|
|
19
19
|
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
20
|
-
import { readJsonl } from "../self-model";
|
|
20
|
+
import { readJsonl } from "../lib/self-model";
|
|
21
21
|
|
|
22
22
|
// ── Config ──
|
|
23
23
|
|
|
@@ -11,99 +11,54 @@
|
|
|
11
11
|
* bun ~/.pal/tools/thread.ts --list [--all]
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { resolve } from "node:path";
|
|
16
14
|
import { parseArgs } from "node:util";
|
|
17
|
-
import { currentAttribution, type RecordAttribution } from "../../hooks/lib/actor";
|
|
18
|
-
import { encodeAnchor } from "../../hooks/lib/anchor";
|
|
19
|
-
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
20
15
|
import { emit } from "../lib/emit";
|
|
16
|
+
import {
|
|
17
|
+
addThread,
|
|
18
|
+
readThreads,
|
|
19
|
+
resolveThreadIn,
|
|
20
|
+
threadsFile,
|
|
21
|
+
visibleThreads,
|
|
22
|
+
writeThreads,
|
|
23
|
+
} from "../lib/thread";
|
|
24
|
+
|
|
25
|
+
const HELP = `
|
|
26
|
+
Thread — Manage open threads across sessions
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
title: string;
|
|
28
|
-
context: string;
|
|
29
|
-
status: "open" | "resolved";
|
|
30
|
-
created: string;
|
|
31
|
-
resolved: string | null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ── Storage ──
|
|
35
|
-
|
|
36
|
-
function threadsPath(): string {
|
|
37
|
-
return resolve(ensureDir(paths.state()), "threads.jsonl");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function generateId(): string {
|
|
41
|
-
return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
42
|
-
}
|
|
28
|
+
Usage:
|
|
29
|
+
thread.ts --add --title "..." [--context "..."]
|
|
30
|
+
thread.ts --resolve --id <id>
|
|
31
|
+
thread.ts --list [--all]
|
|
32
|
+
`;
|
|
43
33
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return readFileSync(p, "utf-8")
|
|
49
|
-
.split("\n")
|
|
50
|
-
.filter((l) => l.trim())
|
|
51
|
-
.map((l) => JSON.parse(l) as Thread);
|
|
52
|
-
} catch {
|
|
53
|
-
return [];
|
|
34
|
+
function add(title: string | undefined, context: string | undefined) {
|
|
35
|
+
if (!title) {
|
|
36
|
+
console.error("--title required");
|
|
37
|
+
process.exit(1);
|
|
54
38
|
}
|
|
39
|
+
const thread = addThread(title, context ?? "");
|
|
40
|
+
emit.receipt(threadsFile(), {
|
|
41
|
+
id: thread.id,
|
|
42
|
+
title: thread.title,
|
|
43
|
+
status: thread.status,
|
|
44
|
+
});
|
|
55
45
|
}
|
|
56
46
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
id: generateId(),
|
|
71
|
-
cwd: encodeAnchor(process.cwd()),
|
|
72
|
-
...currentAttribution(),
|
|
73
|
-
title,
|
|
74
|
-
context,
|
|
75
|
-
status: "open",
|
|
76
|
-
created: new Date().toISOString(),
|
|
77
|
-
resolved: null,
|
|
78
|
-
};
|
|
79
|
-
appendFileSync(threadsPath(), `${JSON.stringify(thread)}\n`, "utf-8");
|
|
80
|
-
return thread;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function resolveThread(id: string): {
|
|
84
|
-
success: boolean;
|
|
85
|
-
thread?: Thread;
|
|
86
|
-
message: string;
|
|
87
|
-
} {
|
|
88
|
-
const threads = readThreads();
|
|
89
|
-
const idx = threads.findIndex((t) => t.id === id);
|
|
90
|
-
if (idx === -1) return { success: false, message: `Thread not found: ${id}` };
|
|
91
|
-
threads[idx].status = "resolved";
|
|
92
|
-
threads[idx].resolved = new Date().toISOString();
|
|
93
|
-
writeThreads(threads);
|
|
94
|
-
return {
|
|
95
|
-
success: true,
|
|
96
|
-
thread: threads[idx],
|
|
97
|
-
message: `Resolved: ${threads[idx].title}`,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function listThreads(all: boolean): Thread[] {
|
|
102
|
-
return all ? readThreads() : readThreads().filter((t) => t.status === "open");
|
|
47
|
+
function markResolved(id: string | undefined) {
|
|
48
|
+
if (!id) {
|
|
49
|
+
console.error("--id required");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const file = threadsFile();
|
|
53
|
+
const resolution = resolveThreadIn(readThreads(file), id, new Date());
|
|
54
|
+
if (!resolution) {
|
|
55
|
+
console.error(`Thread not found: ${id}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
writeThreads(resolution.threads, file);
|
|
59
|
+
emit.receipt(file, { id, status: "resolved", title: resolution.thread.title });
|
|
103
60
|
}
|
|
104
61
|
|
|
105
|
-
// ── CLI ──
|
|
106
|
-
|
|
107
62
|
function run() {
|
|
108
63
|
const { values } = parseArgs({
|
|
109
64
|
args: Bun.argv.slice(2),
|
|
@@ -125,49 +80,14 @@ function run() {
|
|
|
125
80
|
else if (values.list) cmd = "list";
|
|
126
81
|
|
|
127
82
|
if (values.help || !cmd) {
|
|
128
|
-
console.log(
|
|
129
|
-
Thread — Manage open threads across sessions
|
|
130
|
-
|
|
131
|
-
Usage:
|
|
132
|
-
thread.ts --add --title "..." [--context "..."]
|
|
133
|
-
thread.ts --resolve --id <id>
|
|
134
|
-
thread.ts --list [--all]
|
|
135
|
-
`);
|
|
83
|
+
console.log(HELP);
|
|
136
84
|
process.exit(cmd ? 0 : 1);
|
|
137
85
|
}
|
|
138
86
|
|
|
139
|
-
if (cmd === "add")
|
|
140
|
-
|
|
141
|
-
console.error("--title required");
|
|
142
|
-
process.exit(1);
|
|
143
|
-
}
|
|
144
|
-
const thread = addThread(values.title, values.context ?? "");
|
|
145
|
-
emit.receipt(threadsPath(), {
|
|
146
|
-
id: thread.id,
|
|
147
|
-
title: thread.title,
|
|
148
|
-
status: thread.status,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (cmd === "resolve") {
|
|
153
|
-
if (!values.id) {
|
|
154
|
-
console.error("--id required");
|
|
155
|
-
process.exit(1);
|
|
156
|
-
}
|
|
157
|
-
const resolved = resolveThread(values.id);
|
|
158
|
-
if (!resolved.success) {
|
|
159
|
-
console.error(resolved.message);
|
|
160
|
-
process.exit(1);
|
|
161
|
-
}
|
|
162
|
-
emit.receipt(threadsPath(), {
|
|
163
|
-
id: values.id,
|
|
164
|
-
status: "resolved",
|
|
165
|
-
title: resolved.thread?.title,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
87
|
+
if (cmd === "add") add(values.title, values.context);
|
|
88
|
+
if (cmd === "resolve") markResolved(values.id);
|
|
169
89
|
if (cmd === "list") {
|
|
170
|
-
const threads =
|
|
90
|
+
const threads = visibleThreads(readThreads(), values.all ?? false);
|
|
171
91
|
emit.data(JSON.stringify({ count: threads.length, threads }, null, 2));
|
|
172
92
|
}
|
|
173
93
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is waiting on you, and what you have already seen.
|
|
3
|
+
*
|
|
4
|
+
* Every item here is a template over a fact PAL already holds — a refused tool
|
|
5
|
+
* call, an unanswered handoff, a project with no purpose on record. Nothing is
|
|
6
|
+
* inferred: the bell is the one surface that must be instant and always right,
|
|
7
|
+
* and a model in that path would fail silently exactly when it mattered.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
13
|
+
import { readAllProjects } from "../../hooks/lib/projects";
|
|
14
|
+
import { queryLedger } from "../ledger/query";
|
|
15
|
+
import { displayTarget } from "../ledger/view";
|
|
16
|
+
import { freshHandoffs } from "./data";
|
|
17
|
+
import { type AttentionSource, readPrefs } from "./prefs";
|
|
18
|
+
|
|
19
|
+
const DAY_MS = 86_400_000;
|
|
20
|
+
const REFUSAL_WINDOW_DAYS = 7;
|
|
21
|
+
const MAX_ITEMS = 40;
|
|
22
|
+
|
|
23
|
+
export interface AttentionItem {
|
|
24
|
+
id: string;
|
|
25
|
+
source: AttentionSource;
|
|
26
|
+
severity: "alarm" | "waiting" | "note";
|
|
27
|
+
title: string;
|
|
28
|
+
detail: string;
|
|
29
|
+
at: string;
|
|
30
|
+
project: string | null;
|
|
31
|
+
/** Where clicking it should land. */
|
|
32
|
+
href: string;
|
|
33
|
+
read: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AttentionView {
|
|
37
|
+
unread: number;
|
|
38
|
+
items: AttentionItem[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ReadState = Record<string, string>;
|
|
42
|
+
|
|
43
|
+
function readFile(): string {
|
|
44
|
+
return resolve(ensureDir(paths.state()), "attention-read.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readMarks(): ReadState {
|
|
48
|
+
const file = readFile();
|
|
49
|
+
if (!existsSync(file)) return {};
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(file, "utf-8")) as ReadState;
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeMarks(marks: ReadState): void {
|
|
58
|
+
writeFileSync(readFile(), `${JSON.stringify(marks, null, 2)}\n`, "utf-8");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function markRead(ids: string[], now: Date = new Date()): number {
|
|
62
|
+
const marks = readMarks();
|
|
63
|
+
for (const id of ids) marks[id] = now.toISOString();
|
|
64
|
+
writeMarks(marks);
|
|
65
|
+
return ids.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function refusals(now: Date): AttentionItem[] {
|
|
69
|
+
const since = new Date(now.getTime() - REFUSAL_WINDOW_DAYS * DAY_MS);
|
|
70
|
+
return queryLedger({ since })
|
|
71
|
+
.filter((e) => e.outcome === "denied" || e.outcome === "blocked")
|
|
72
|
+
.map((e) => ({
|
|
73
|
+
id: `refusal:${e.id}`,
|
|
74
|
+
source: "refusals" as const,
|
|
75
|
+
severity: "alarm" as const,
|
|
76
|
+
title: `${e.outcome === "blocked" ? "Blocked" : "Denied"}: ${e.tool}`,
|
|
77
|
+
detail: e.reason ?? e.command ?? displayTarget(e.target),
|
|
78
|
+
at: e.ts,
|
|
79
|
+
project: anchorProject(e.target),
|
|
80
|
+
href: `/log?outcome=${e.outcome}`,
|
|
81
|
+
read: false,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function anchorProject(target: string): string | null {
|
|
86
|
+
const match = /^\{proj:([^}]+)\}/.exec(target);
|
|
87
|
+
return match ? match[1] : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function waiting(now: Date): AttentionItem[] {
|
|
91
|
+
const slugByPath = new Map(
|
|
92
|
+
readAllProjects()
|
|
93
|
+
.filter((p) => p.path)
|
|
94
|
+
.map((p) => [p.path as string, p.name])
|
|
95
|
+
);
|
|
96
|
+
return freshHandoffs(now)
|
|
97
|
+
.filter(([, h]) => h.waitingOn)
|
|
98
|
+
.map(([cwd, h]) => {
|
|
99
|
+
const slug = slugByPath.get(cwd) ?? null;
|
|
100
|
+
return {
|
|
101
|
+
id: `waiting:${cwd}:${h.timestamp}`,
|
|
102
|
+
source: "waiting" as const,
|
|
103
|
+
severity: "waiting" as const,
|
|
104
|
+
title: `${slug ?? cwd} is waiting on you`,
|
|
105
|
+
detail: h.waitingOn as string,
|
|
106
|
+
at: h.timestamp,
|
|
107
|
+
project: slug,
|
|
108
|
+
href: slug ? `/projects/${slug}` : "/",
|
|
109
|
+
read: false,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unranked(): AttentionItem[] {
|
|
115
|
+
return readAllProjects()
|
|
116
|
+
.filter((p) => p.status === "active" && !p.serves)
|
|
117
|
+
.map((p) => ({
|
|
118
|
+
id: `unranked:${p.name}`,
|
|
119
|
+
source: "unranked" as const,
|
|
120
|
+
severity: "note" as const,
|
|
121
|
+
title: `${p.name} has no purpose on record`,
|
|
122
|
+
detail: "It ranks as unimportant until you say what it serves.",
|
|
123
|
+
at: p.updated,
|
|
124
|
+
project: p.name,
|
|
125
|
+
href: `/projects/${p.name}`,
|
|
126
|
+
read: false,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SOURCES: Record<AttentionSource, (now: Date) => AttentionItem[]> = {
|
|
131
|
+
refusals,
|
|
132
|
+
waiting,
|
|
133
|
+
unranked: () => unranked(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function attention(now: Date = new Date()): AttentionView {
|
|
137
|
+
const prefs = readPrefs();
|
|
138
|
+
const marks = readMarks();
|
|
139
|
+
const items = (Object.keys(SOURCES) as AttentionSource[])
|
|
140
|
+
.filter((source) => prefs.attention[source])
|
|
141
|
+
.flatMap((source) => SOURCES[source](now))
|
|
142
|
+
.map((item) => ({ ...item, read: marks[item.id] !== undefined }))
|
|
143
|
+
.sort((a, b) => b.at.localeCompare(a.at))
|
|
144
|
+
.slice(0, MAX_ITEMS);
|
|
145
|
+
return { unread: items.filter((i) => !i.read).length, items };
|
|
146
|
+
}
|