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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flags the relationship-note tool takes, turned into notes to append.
|
|
3
|
+
*
|
|
4
|
+
* The tool is only ever spawned, so the one judgement here — what counts as a
|
|
5
|
+
* usable confidence, and what an empty invocation should do — could not be
|
|
6
|
+
* asserted from a test. A bad confidence is a typo, and writing the note anyway
|
|
7
|
+
* under a default would record a claim nobody made.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type NoteType = "O" | "W" | "Session";
|
|
11
|
+
|
|
12
|
+
interface NoteDraft {
|
|
13
|
+
type: NoteType;
|
|
14
|
+
text: string;
|
|
15
|
+
confidence?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NoteFlags {
|
|
19
|
+
o?: string[];
|
|
20
|
+
w?: string[];
|
|
21
|
+
b?: string;
|
|
22
|
+
confidence?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What an opinion is worth when the caller did not say. */
|
|
26
|
+
export const DEFAULT_CONFIDENCE = 0.75;
|
|
27
|
+
|
|
28
|
+
export type NoteFlagsResult = { notes: NoteDraft[] } | { error: string };
|
|
29
|
+
|
|
30
|
+
/** parseArgs leaves it a string; anything outside 0–1 is a typo, not a value. */
|
|
31
|
+
export function parseConfidence(raw: string | undefined): number | null {
|
|
32
|
+
if (raw === undefined) return DEFAULT_CONFIDENCE;
|
|
33
|
+
const value = Number.parseFloat(raw);
|
|
34
|
+
if (Number.isNaN(value) || value < 0 || value > 1) return null;
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function notesFromFlags(flags: NoteFlags): NoteFlagsResult {
|
|
39
|
+
const opinions = flags.o ?? [];
|
|
40
|
+
const facts = flags.w ?? [];
|
|
41
|
+
if (opinions.length === 0 && facts.length === 0 && !flags.b) {
|
|
42
|
+
return { error: "Required: at least one of --o, --w, --b" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const notes: NoteDraft[] = [];
|
|
46
|
+
|
|
47
|
+
if (opinions.length > 0) {
|
|
48
|
+
const confidence = parseConfidence(flags.confidence);
|
|
49
|
+
if (confidence === null) {
|
|
50
|
+
return { error: "--confidence must be a number between 0.0 and 1.0" };
|
|
51
|
+
}
|
|
52
|
+
for (const text of opinions) notes.push({ type: "O", text, confidence });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const text of facts) notes.push({ type: "W", text });
|
|
56
|
+
if (flags.b) notes.push({ type: "Session", text: flags.b });
|
|
57
|
+
|
|
58
|
+
return { notes };
|
|
59
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISCs — the numbered criteria a project is judged against, stored as markdown
|
|
3
|
+
* lines inside a project's ISA.md.
|
|
4
|
+
*
|
|
5
|
+
* The tool that edits them is only ever spawned, so the line format, the
|
|
6
|
+
* escaping that keeps a multi-paragraph criterion on one line, and the archive
|
|
7
|
+
* bookkeeping were reachable only by running the CLI. Each function here takes
|
|
8
|
+
* the text it operates on, plus the clock it stamps with.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type IscStatus = "open" | "done" | "retired";
|
|
12
|
+
|
|
13
|
+
export const ISC_BOX: Record<IscStatus, string> = {
|
|
14
|
+
open: "[ ]",
|
|
15
|
+
done: "[x]",
|
|
16
|
+
retired: "[~]",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function statusFromBox(box: string): IscStatus {
|
|
20
|
+
if (box.toLowerCase() === "x") return "done";
|
|
21
|
+
if (box === "~") return "retired";
|
|
22
|
+
return "open";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Isc {
|
|
26
|
+
id: number;
|
|
27
|
+
text: string;
|
|
28
|
+
status: IscStatus;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An ISC is one markdown line, so a newline in its text would end the record
|
|
33
|
+
* and strand every paragraph after it as unparseable debris. Backslashes are
|
|
34
|
+
* escaped first so that decoding a literal "\n" in a regex cannot be mistaken
|
|
35
|
+
* for the separator.
|
|
36
|
+
*/
|
|
37
|
+
export function encodeIscText(text: string): string {
|
|
38
|
+
return text
|
|
39
|
+
.replaceAll("\\", "\\\\")
|
|
40
|
+
.replaceAll("\r\n", "\n")
|
|
41
|
+
.replaceAll("\r", "\n")
|
|
42
|
+
.replaceAll("\n", "\\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
|
|
46
|
+
|
|
47
|
+
export function decodeIscText(stored: string): string {
|
|
48
|
+
return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseIscs(criteria: string): Isc[] {
|
|
52
|
+
const out: Isc[] = [];
|
|
53
|
+
for (const line of criteria.split("\n")) {
|
|
54
|
+
const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
55
|
+
if (m)
|
|
56
|
+
out.push({
|
|
57
|
+
id: Number(m[2]),
|
|
58
|
+
text: decodeIscText(m[3].trim()),
|
|
59
|
+
status: statusFromBox(m[1]),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A full ISC line collapsed to a glanceable title for resume: cut at the first
|
|
67
|
+
* clause boundary, then hard-cap length. Full text stays reachable via show-isc.
|
|
68
|
+
*/
|
|
69
|
+
export function iscTitle(text: string): string {
|
|
70
|
+
const boundary = text.search(/; | — | \(|\. /);
|
|
71
|
+
const clause = (boundary > 0 ? text.slice(0, boundary) : text).trim();
|
|
72
|
+
return clause.length > 80 ? `${clause.slice(0, 79).trimEnd()}…` : clause;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Scans Criteria AND Changelog so an archived id can never be handed out again. */
|
|
76
|
+
export function nextIscId(criteria: string, changelog: string): number {
|
|
77
|
+
const ids = [...parseIscs(criteria), ...parseIscs(changelog)].map((i) => i.id);
|
|
78
|
+
return ids.length > 0 ? Math.max(...ids) + 1 : 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function removeIscLine(
|
|
82
|
+
section: string,
|
|
83
|
+
id: number
|
|
84
|
+
): { line: string | null; rest: string } {
|
|
85
|
+
const lines = section.split("\n");
|
|
86
|
+
const idx = lines.findIndex((l) =>
|
|
87
|
+
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`).test(l)
|
|
88
|
+
);
|
|
89
|
+
if (idx === -1) return { line: null, rest: section };
|
|
90
|
+
const [line] = lines.splice(idx, 1);
|
|
91
|
+
return {
|
|
92
|
+
line,
|
|
93
|
+
rest: lines
|
|
94
|
+
.join("\n")
|
|
95
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
96
|
+
.trim(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Drops any "### Archived <date>" heading whose block has no content left —
|
|
102
|
+
* e.g. after every ISC filed under that date has been reopened.
|
|
103
|
+
*/
|
|
104
|
+
export function dropEmptyArchiveHeadings(changelog: string): string {
|
|
105
|
+
const lines = changelog.split("\n");
|
|
106
|
+
const blockHasContent = (headingIdx: number): boolean => {
|
|
107
|
+
for (let j = headingIdx + 1; j < lines.length && !lines[j].startsWith("### "); j++) {
|
|
108
|
+
if (lines[j].trim() !== "") return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
};
|
|
112
|
+
return lines
|
|
113
|
+
.filter((l, i) => !(/^### Archived /.test(l) && !blockHasContent(i)))
|
|
114
|
+
.join("\n")
|
|
115
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
116
|
+
.trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function archiveLine(
|
|
120
|
+
changelog: string | undefined,
|
|
121
|
+
doneLine: string,
|
|
122
|
+
kind: "Archived" | "Retired" = "Archived",
|
|
123
|
+
now: Date = new Date()
|
|
124
|
+
): string {
|
|
125
|
+
const heading = `### ${kind} ${now.toISOString().slice(0, 10)}`;
|
|
126
|
+
const base = (changelog ?? "").trim();
|
|
127
|
+
if (base.includes(heading)) return `${base}\n${doneLine}`;
|
|
128
|
+
return base ? `${base}\n\n${heading}\n${doneLine}` : `${heading}\n${doneLine}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function selectIscs(
|
|
132
|
+
open: Isc[],
|
|
133
|
+
done: Isc[],
|
|
134
|
+
retired: Isc[],
|
|
135
|
+
flags: Set<string>
|
|
136
|
+
): Isc[] {
|
|
137
|
+
if (flags.has("--all")) return [...open, ...done, ...retired];
|
|
138
|
+
if (flags.has("--closed")) return done;
|
|
139
|
+
if (flags.has("--retired")) return retired;
|
|
140
|
+
return open;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface IscSections {
|
|
144
|
+
criteria: string;
|
|
145
|
+
changelog: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type IscMove =
|
|
149
|
+
| ({ ok: true; already: boolean } & IscSections)
|
|
150
|
+
| { ok: false; reason: string };
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Closing an ISC moves its line out of Criteria and files it under a dated
|
|
154
|
+
* archive heading in the Changelog. Reopening walks it back. Both are pure so
|
|
155
|
+
* the CLI and the control room reach the same record through one rule.
|
|
156
|
+
*/
|
|
157
|
+
export function completeIsc(
|
|
158
|
+
sections: IscSections,
|
|
159
|
+
id: number,
|
|
160
|
+
now: Date = new Date()
|
|
161
|
+
): IscMove {
|
|
162
|
+
const kept = { criteria: sections.criteria, changelog: sections.changelog };
|
|
163
|
+
if (parseIscs(sections.changelog).some((i) => i.id === id)) {
|
|
164
|
+
return { ok: true, already: true, ...kept };
|
|
165
|
+
}
|
|
166
|
+
const { line, rest } = removeIscLine(sections.criteria, id);
|
|
167
|
+
if (!line) return { ok: false, reason: `ISC-${id} not found` };
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
already: false,
|
|
171
|
+
criteria: rest,
|
|
172
|
+
changelog: archiveLine(
|
|
173
|
+
sections.changelog,
|
|
174
|
+
line.replace("[ ]", "[x]"),
|
|
175
|
+
"Archived",
|
|
176
|
+
now
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function reopenIsc(sections: IscSections, id: number): IscMove {
|
|
182
|
+
const kept = { criteria: sections.criteria, changelog: sections.changelog };
|
|
183
|
+
if (parseIscs(sections.criteria).some((i) => i.id === id && i.status === "open")) {
|
|
184
|
+
return { ok: true, already: true, ...kept };
|
|
185
|
+
}
|
|
186
|
+
let changelog = sections.changelog;
|
|
187
|
+
let criteria = sections.criteria;
|
|
188
|
+
let removed = removeIscLine(changelog, id);
|
|
189
|
+
if (removed.line) changelog = dropEmptyArchiveHeadings(removed.rest);
|
|
190
|
+
else {
|
|
191
|
+
removed = removeIscLine(criteria, id);
|
|
192
|
+
if (removed.line) criteria = removed.rest;
|
|
193
|
+
}
|
|
194
|
+
if (!removed.line) return { ok: false, reason: `ISC-${id} not found` };
|
|
195
|
+
const openLine = removed.line.replace(/\[[x~]\]/i, "[ ]");
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
already: false,
|
|
199
|
+
criteria: criteria ? `${criteria.trimEnd()}\n${openLine}` : openLine,
|
|
200
|
+
changelog,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A filesystem-safe stem for a task's own ISA, kept unique by the clock. */
|
|
205
|
+
export function taskSlug(title: string, now: number = Date.now()): string {
|
|
206
|
+
const sanitized = title
|
|
207
|
+
.toLowerCase()
|
|
208
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
209
|
+
.replace(/^-+|-+$/g, "")
|
|
210
|
+
.slice(0, 40);
|
|
211
|
+
return `${sanitized}-${now.toString(36)}`;
|
|
212
|
+
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Periodic reflection on relationship notes: what recurs, what it says about
|
|
3
|
+
* the user, and which of it has earned a tracked opinion.
|
|
4
|
+
*
|
|
5
|
+
* The tool around this is only ever spawned, so none of the judgement was
|
|
6
|
+
* reachable from a test — not the note grammar, not the similarity grouping
|
|
7
|
+
* that decides two notes are the same observation, not the rule that a note
|
|
8
|
+
* needs a second sighting before it becomes an opinion.
|
|
9
|
+
*
|
|
10
|
+
* The readers take the directory to read and the clock to read it against,
|
|
11
|
+
* which is what makes a fixed corpus at a fixed date assertable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { addEvidence, createOpinion, findSimilarOpinion } from "../../hooks/lib/opinions";
|
|
17
|
+
import { similarity } from "../../hooks/lib/text-similarity";
|
|
18
|
+
|
|
19
|
+
type Opinion = ReturnType<typeof createOpinion>;
|
|
20
|
+
|
|
21
|
+
export interface Rating {
|
|
22
|
+
ts: string;
|
|
23
|
+
rating: number;
|
|
24
|
+
context: string;
|
|
25
|
+
source: "explicit" | "implicit";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParsedNote {
|
|
29
|
+
type: "W" | "O" | "Session";
|
|
30
|
+
text: string;
|
|
31
|
+
confidence?: number;
|
|
32
|
+
date: string;
|
|
33
|
+
time: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OpinionChange {
|
|
37
|
+
statement: string;
|
|
38
|
+
action: "created" | "strengthened";
|
|
39
|
+
oldConfidence?: number;
|
|
40
|
+
newConfidence: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OpinionSummary {
|
|
44
|
+
text: string;
|
|
45
|
+
occurrences: number;
|
|
46
|
+
avgConfidence: number;
|
|
47
|
+
dates: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What reflection decided to do, kept separate from doing it. */
|
|
51
|
+
export interface PromotionPlan {
|
|
52
|
+
changes: OpinionChange[];
|
|
53
|
+
toSave: Opinion[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Two notes count as the same observation from here up. */
|
|
57
|
+
const SAME_OBSERVATION = 0.3;
|
|
58
|
+
|
|
59
|
+
/** A note needs a second sighting before it earns an opinion of its own. */
|
|
60
|
+
const SIGHTINGS_TO_PROMOTE = 2;
|
|
61
|
+
|
|
62
|
+
const EVIDENCE_CHARS = 120;
|
|
63
|
+
const LOW_RATING = 4;
|
|
64
|
+
const HIGH_RATING = 7;
|
|
65
|
+
const HIGH_CONFIDENCE = 0.85;
|
|
66
|
+
const MONTH_DIR = /^\d{4}-\d{2}$/;
|
|
67
|
+
const TIME_HEADING = /^## (\d{2}:\d{2})/;
|
|
68
|
+
const SCORED_NOTE = /^- ([OB])\(c=([\d.]+)\):\s*(.+)$/;
|
|
69
|
+
const WORLD_NOTE = /^- W:\s*(.+)$/;
|
|
70
|
+
|
|
71
|
+
export function cutoffDate(daysBack: number, now: Date): Date {
|
|
72
|
+
const cutoff = new Date(now);
|
|
73
|
+
cutoff.setDate(cutoff.getDate() - daysBack);
|
|
74
|
+
return cutoff;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One day's note file. A time heading applies to every note under it, so the
|
|
79
|
+
* heading is carried forward rather than re-read per note.
|
|
80
|
+
*/
|
|
81
|
+
export function parseNoteFile(content: string, date: string): ParsedNote[] {
|
|
82
|
+
const notes: ParsedNote[] = [];
|
|
83
|
+
let time = "";
|
|
84
|
+
|
|
85
|
+
for (const line of content.split("\n")) {
|
|
86
|
+
const heading = TIME_HEADING.exec(line);
|
|
87
|
+
if (heading) {
|
|
88
|
+
time = heading[1];
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const scored = SCORED_NOTE.exec(line);
|
|
93
|
+
if (scored) {
|
|
94
|
+
notes.push({
|
|
95
|
+
type: scored[1] === "O" ? "O" : "Session",
|
|
96
|
+
confidence: Number.parseFloat(scored[2]),
|
|
97
|
+
text: scored[3],
|
|
98
|
+
date,
|
|
99
|
+
time,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const world = WORLD_NOTE.exec(line);
|
|
105
|
+
if (world) notes.push({ type: "W", text: world[1], date, time });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return notes;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function loadNotes(
|
|
112
|
+
relationshipDir: string,
|
|
113
|
+
daysBack: number,
|
|
114
|
+
now: Date = new Date()
|
|
115
|
+
): ParsedNote[] {
|
|
116
|
+
if (!existsSync(relationshipDir)) return [];
|
|
117
|
+
|
|
118
|
+
const cutoff = cutoffDate(daysBack, now);
|
|
119
|
+
const notes: ParsedNote[] = [];
|
|
120
|
+
|
|
121
|
+
for (const monthDir of readdirSync(relationshipDir).sort().reverse()) {
|
|
122
|
+
if (!MONTH_DIR.test(monthDir)) continue;
|
|
123
|
+
const monthPath = resolve(relationshipDir, monthDir);
|
|
124
|
+
|
|
125
|
+
let files: string[];
|
|
126
|
+
try {
|
|
127
|
+
files = readdirSync(monthPath)
|
|
128
|
+
.filter((f) => f.endsWith(".md"))
|
|
129
|
+
.sort()
|
|
130
|
+
.reverse();
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
const date = file.replace(".md", "");
|
|
137
|
+
if (new Date(date) < cutoff) continue;
|
|
138
|
+
try {
|
|
139
|
+
notes.push(
|
|
140
|
+
...parseNoteFile(readFileSync(resolve(monthPath, file), "utf-8"), date)
|
|
141
|
+
);
|
|
142
|
+
} catch {
|
|
143
|
+
/* unreadable day */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return notes;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseRatings(content: string, daysBack: number, now: Date): Rating[] {
|
|
152
|
+
const cutoff = cutoffDate(daysBack, now).getTime();
|
|
153
|
+
|
|
154
|
+
return content
|
|
155
|
+
.split("\n")
|
|
156
|
+
.filter((line) => line.trim())
|
|
157
|
+
.map((line) => {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(line) as Rating;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
.filter((r): r is Rating => r !== null && new Date(r.ts).getTime() >= cutoff);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function loadRatings(
|
|
168
|
+
ratingsFile: string,
|
|
169
|
+
daysBack: number,
|
|
170
|
+
now: Date = new Date()
|
|
171
|
+
): Rating[] {
|
|
172
|
+
if (!existsSync(ratingsFile)) return [];
|
|
173
|
+
return parseRatings(readFileSync(ratingsFile, "utf-8"), daysBack, now);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Collapse notes saying the same thing into one group, keyed by the first note
|
|
178
|
+
* that said it. First match wins, so a note joins the earliest group it is
|
|
179
|
+
* close enough to rather than the closest one.
|
|
180
|
+
*/
|
|
181
|
+
function groupBySimilarity(notes: ParsedNote[]): Map<string, ParsedNote[]> {
|
|
182
|
+
const groups = new Map<string, ParsedNote[]>();
|
|
183
|
+
|
|
184
|
+
for (const note of notes) {
|
|
185
|
+
const key = [...groups.keys()].find(
|
|
186
|
+
(existing) => similarity(note.text, existing) >= SAME_OBSERVATION
|
|
187
|
+
);
|
|
188
|
+
if (key === undefined) groups.set(note.text, [note]);
|
|
189
|
+
else groups.get(key)?.push(note);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return groups;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const opinionNotesOf = (notes: ParsedNote[]) => notes.filter((n) => n.type === "O");
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* An observation already tracked gains evidence; one seen twice with nothing
|
|
199
|
+
* tracked becomes an opinion. A single unmatched sighting is left alone — it is
|
|
200
|
+
* an anecdote until it repeats.
|
|
201
|
+
*/
|
|
202
|
+
export function planPromotions(notes: ParsedNote[], opinions: Opinion[]): PromotionPlan {
|
|
203
|
+
const plan: PromotionPlan = { changes: [], toSave: [] };
|
|
204
|
+
|
|
205
|
+
for (const [statement, group] of groupBySimilarity(opinionNotesOf(notes))) {
|
|
206
|
+
const existing = findSimilarOpinion(statement, opinions);
|
|
207
|
+
|
|
208
|
+
if (existing) {
|
|
209
|
+
const updated = group.reduce(
|
|
210
|
+
(opinion, note) =>
|
|
211
|
+
addEvidence(opinion, "supporting", note.text.slice(0, EVIDENCE_CHARS)),
|
|
212
|
+
existing
|
|
213
|
+
);
|
|
214
|
+
if (updated.confidence === existing.confidence) continue;
|
|
215
|
+
plan.changes.push({
|
|
216
|
+
statement: existing.statement,
|
|
217
|
+
action: "strengthened",
|
|
218
|
+
oldConfidence: existing.confidence,
|
|
219
|
+
newConfidence: updated.confidence,
|
|
220
|
+
});
|
|
221
|
+
plan.toSave.push(updated);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (group.length < SIGHTINGS_TO_PROMOTE) continue;
|
|
226
|
+
const opinion = group
|
|
227
|
+
.slice(1)
|
|
228
|
+
.reduce(
|
|
229
|
+
(o, note) => addEvidence(o, "supporting", note.text.slice(0, EVIDENCE_CHARS)),
|
|
230
|
+
createOpinion(statement, group[0].text.slice(0, EVIDENCE_CHARS))
|
|
231
|
+
);
|
|
232
|
+
plan.changes.push({
|
|
233
|
+
statement,
|
|
234
|
+
action: "created",
|
|
235
|
+
newConfidence: opinion.confidence,
|
|
236
|
+
});
|
|
237
|
+
plan.toSave.push(opinion);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return plan;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function groupNoteOccurrences(notes: ParsedNote[]): OpinionSummary[] {
|
|
244
|
+
return [...groupBySimilarity(opinionNotesOf(notes)).values()]
|
|
245
|
+
.map((group) => {
|
|
246
|
+
const confidences = group
|
|
247
|
+
.map((n) => n.confidence)
|
|
248
|
+
.filter((c): c is number => c !== undefined);
|
|
249
|
+
return {
|
|
250
|
+
text: group[0].text,
|
|
251
|
+
occurrences: group.length,
|
|
252
|
+
avgConfidence:
|
|
253
|
+
confidences.length > 0
|
|
254
|
+
? confidences.reduce((a, b) => a + b, 0) / confidences.length
|
|
255
|
+
: 0,
|
|
256
|
+
dates: [...new Set(group.map((n) => n.date))],
|
|
257
|
+
};
|
|
258
|
+
})
|
|
259
|
+
.sort((a, b) => b.occurrences - a.occurrences);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function averageRating(ratings: Rating[]): number {
|
|
263
|
+
if (ratings.length === 0) return 0;
|
|
264
|
+
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const contextsOf = (ratings: Rating[]) =>
|
|
268
|
+
ratings
|
|
269
|
+
.slice(0, 3)
|
|
270
|
+
.map((r) => `"${r.context.slice(0, 60)}"`)
|
|
271
|
+
.join(", ");
|
|
272
|
+
|
|
273
|
+
export function correlateRatings(ratings: Rating[]): string[] {
|
|
274
|
+
const low = ratings.filter((r) => r.rating <= LOW_RATING);
|
|
275
|
+
const high = ratings.filter((r) => r.rating >= HIGH_RATING);
|
|
276
|
+
const insights: string[] = [];
|
|
277
|
+
|
|
278
|
+
if (low.length > 0) {
|
|
279
|
+
insights.push(
|
|
280
|
+
`${low.length} low ratings (<=${LOW_RATING}) — common contexts: ${contextsOf(low)}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (high.length > 0) {
|
|
284
|
+
insights.push(
|
|
285
|
+
`${high.length} high ratings (>=${HIGH_RATING}) — common contexts: ${contextsOf(high)}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (ratings.length > 0) {
|
|
289
|
+
const explicit = ratings.filter((r) => r.source === "explicit").length;
|
|
290
|
+
insights.push(
|
|
291
|
+
`Source mix: ${explicit} explicit, ${ratings.length - explicit} implicit`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return insights;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const pct = (confidence: number) => Math.round(confidence * 100);
|
|
299
|
+
|
|
300
|
+
export function changeLine(change: OpinionChange): string {
|
|
301
|
+
if (change.action === "created") {
|
|
302
|
+
return `- **NEW** (${pct(change.newConfidence)}%): ${change.statement}`;
|
|
303
|
+
}
|
|
304
|
+
const from = pct(change.oldConfidence ?? 0);
|
|
305
|
+
return `- **+** ${from}% → ${pct(change.newConfidence)}%: ${change.statement}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function formatReport(
|
|
309
|
+
period: string,
|
|
310
|
+
notes: ParsedNote[],
|
|
311
|
+
ratings: Rating[],
|
|
312
|
+
changes: OpinionChange[],
|
|
313
|
+
now: Date = new Date()
|
|
314
|
+
): string {
|
|
315
|
+
const lines: string[] = [
|
|
316
|
+
"# Relationship Reflection",
|
|
317
|
+
"",
|
|
318
|
+
`**Period:** ${period}`,
|
|
319
|
+
`**Generated:** ${now.toISOString().slice(0, 10)}`,
|
|
320
|
+
`**Notes analyzed:** ${notes.length}`,
|
|
321
|
+
`**Ratings analyzed:** ${ratings.length}`,
|
|
322
|
+
`**Average Rating:** ${averageRating(ratings).toFixed(1)}/10`,
|
|
323
|
+
"",
|
|
324
|
+
"---",
|
|
325
|
+
"",
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
if (changes.length > 0) {
|
|
329
|
+
lines.push("## Opinion Changes", "", ...changes.map(changeLine), "");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const summaries = groupNoteOccurrences(notes);
|
|
333
|
+
if (summaries.length > 0) {
|
|
334
|
+
lines.push("## Recurring Opinions", "");
|
|
335
|
+
for (const op of summaries) {
|
|
336
|
+
const stats = ` Seen ${op.occurrences}x | Avg confidence: ${op.avgConfidence.toFixed(2)} | Dates: ${op.dates.join(", ")}`;
|
|
337
|
+
lines.push(`- **${op.text}**`, stats, "");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const facts = notes.filter((n) => n.type === "W").map((n) => n.text);
|
|
342
|
+
if (facts.length > 0) {
|
|
343
|
+
lines.push(
|
|
344
|
+
"## World Facts Observed",
|
|
345
|
+
"",
|
|
346
|
+
...facts.slice(0, 10).map((f) => `- ${f}`),
|
|
347
|
+
""
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const insights = correlateRatings(ratings);
|
|
352
|
+
if (insights.length > 0) {
|
|
353
|
+
lines.push("## Rating Insights", "", ...insights.map((i) => `- ${i}`), "");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return lines.join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function reportPath(
|
|
360
|
+
reflectionDir: string,
|
|
361
|
+
period: string,
|
|
362
|
+
now: Date = new Date()
|
|
363
|
+
): string {
|
|
364
|
+
const date = now.toISOString().slice(0, 10);
|
|
365
|
+
const slug = period.toLowerCase().replace(/\s+/g, "-");
|
|
366
|
+
return resolve(reflectionDir, `${date}_${slug}-reflection.md`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The same changes as the report, abbreviated for a terminal. */
|
|
370
|
+
export function consoleLines(
|
|
371
|
+
notes: ParsedNote[],
|
|
372
|
+
ratings: Rating[],
|
|
373
|
+
changes: OpinionChange[]
|
|
374
|
+
): string[] {
|
|
375
|
+
const lines = [
|
|
376
|
+
`\nAverage Rating: ${averageRating(ratings).toFixed(1)}/10`,
|
|
377
|
+
`Observations: ${groupNoteOccurrences(notes).length} unique`,
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
if (changes.length === 0) return [...lines, "\nNo opinion changes"];
|
|
381
|
+
|
|
382
|
+
lines.push("\nOpinion changes:");
|
|
383
|
+
for (const change of changes) {
|
|
384
|
+
const statement = change.statement.slice(0, 80);
|
|
385
|
+
lines.push(
|
|
386
|
+
change.action === "created"
|
|
387
|
+
? ` + NEW (${pct(change.newConfidence)}%) ${statement}`
|
|
388
|
+
: ` ~ ${pct(change.oldConfidence ?? 0)}% → ${pct(change.newConfidence)}% ${statement}`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return lines;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Nothing to say unless something crossed the bar that gets it into context. */
|
|
395
|
+
export function highConfidenceLines(opinions: Opinion[]): string[] {
|
|
396
|
+
const high = opinions.filter((o) => o.confidence >= HIGH_CONFIDENCE);
|
|
397
|
+
if (high.length === 0) return [];
|
|
398
|
+
return [
|
|
399
|
+
"\nHigh-confidence opinions (injected into context):",
|
|
400
|
+
...high.map((o) => ` [${pct(o.confidence)}%] ${o.statement.slice(0, 80)}`),
|
|
401
|
+
];
|
|
402
|
+
}
|