portable-agent-layer 0.51.2 → 0.53.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.
@@ -6,15 +6,16 @@
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { resolve } from "node:path";
9
+ import { loadReflectNudge } from "../handlers/reflect-trigger";
9
10
  import { loadAlgorithmReviewNudge } from "./algorithm-review";
11
+ import { loadAnalyzeNudge } from "./analyze-nudge";
10
12
  import { readLearnings } from "./learning-store";
11
13
  import { loadOpinionContext } from "./opinions";
12
14
  import { paths } from "./paths";
13
15
  import { loadActiveProjectsContext } from "./projects";
14
16
  import { loadRecentNotes } from "./relationship";
15
- import { loadFailurePatterns, loadSynthesisRecommendations } from "./semi-static";
17
+ import { loadFailurePatterns } from "./semi-static";
16
18
  import * as settings from "./settings";
17
- import { computeSignalTrends, formatTrends } from "./signal-trends";
18
19
  import { readFramePrinciples } from "./wisdom";
19
20
  import { readProjectHistory } from "./work-tracking";
20
21
 
@@ -87,15 +88,6 @@ function loadSelfModel(): string {
87
88
  }
88
89
  }
89
90
 
90
- /** Load signal trends as a formatted string */
91
- export function loadSignalTrends(): string {
92
- try {
93
- return formatTrends(computeSignalTrends());
94
- } catch {
95
- return "";
96
- }
97
- }
98
-
99
91
  /** Load per-project session history for the current working directory */
100
92
  function loadProjectHistoryContext(): string {
101
93
  try {
@@ -298,13 +290,8 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
298
290
  const activeProjects = settings.isEnabled("projects")
299
291
  ? loadActiveProjectsContext()
300
292
  : "";
301
- const trends = settings.isEnabled("signalTrends") ? loadSignalTrends() : "";
302
293
  const failures =
303
294
  settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
304
- const synthesis =
305
- settings.isEnabled("synthesis") && !skipSemiStatic
306
- ? loadSynthesisRecommendations()
307
- : "";
308
295
  const opinions =
309
296
  !skipSemiStatic && settings.isEnabled("opinions") ? loadOpinionContext() : "";
310
297
  const selfModel =
@@ -315,10 +302,14 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
315
302
  const handoff = settings.isEnabled("handoff") ? loadHandoff() : "";
316
303
  // Maintainer-only: self-gates to a repo checkout, "" for everyone else.
317
304
  const algoReview = loadAlgorithmReviewNudge();
305
+ const reflectNudge = loadReflectNudge();
306
+ const analyzeNudge = loadAnalyzeNudge();
318
307
  const parts: string[] = [];
319
308
  if (startup) parts.push(startup);
320
309
  if (handoff) parts.push(handoff);
321
310
  if (algoReview) parts.push(algoReview);
311
+ if (reflectNudge) parts.push(reflectNudge);
312
+ if (analyzeNudge) parts.push(analyzeNudge);
322
313
  if (selfModel) parts.push(selfModel);
323
314
  if (wisdom) parts.push(wisdom);
324
315
  if (opinions) parts.push(opinions);
@@ -327,8 +318,6 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
327
318
  if (activeProjects) parts.push(activeProjects);
328
319
  if (projectHistory) parts.push(projectHistory);
329
320
  if (digest) parts.push(digest);
330
- if (synthesis) parts.push(synthesis);
331
- if (trends) parts.push(trends);
332
321
  if (failures) parts.push(failures);
333
322
  if (parts.length === 0) return "";
334
323
 
@@ -5,7 +5,14 @@
5
5
  * Only writes when debug is enabled (`pal cli debug on`) or when called via logError (always logged).
6
6
  */
7
7
 
8
- import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "node:fs";
8
+ import {
9
+ appendFileSync,
10
+ existsSync,
11
+ renameSync,
12
+ statSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
9
16
  import { resolve } from "node:path";
10
17
  import { palHome, paths } from "./paths";
11
18
 
@@ -14,7 +21,7 @@ const MAX_ROTATED = 5; // keep up to 5 rotated files (.1 newest → .5 oldest)
14
21
 
15
22
  /** Resolved lazily so PAL_HOME overrides at runtime are honored. */
16
23
  function logFile(): string {
17
- return resolve(paths.state(), "debug.log");
24
+ return resolve(paths.debug(), "debug.log");
18
25
  }
19
26
 
20
27
  function timestamp(): string {
@@ -90,6 +97,31 @@ export function logDebug(source: string, message: string): void {
90
97
  }
91
98
  }
92
99
 
100
+ /** Write last user prompt + retrieval injection to last-prompt.md (only when debug is enabled) */
101
+ export function logPromptSnapshot(prompt: string, retrieval: string | null): void {
102
+ if (!isDebugEnabled()) return;
103
+ const path = resolve(paths.debug(), "last-prompt.md");
104
+ const content = retrieval
105
+ ? `## Prompt\n\n${prompt}\n\n## Retrieval Injection\n\n${retrieval}`
106
+ : `## Prompt\n\n${prompt}`;
107
+ try {
108
+ writeFileSync(path, content, "utf-8");
109
+ } catch {
110
+ /* non-critical */
111
+ }
112
+ }
113
+
114
+ /** Write full context snapshot to context-snapshot.md (only when debug is enabled) */
115
+ export function logContextSnapshot(content: string): void {
116
+ if (!isDebugEnabled()) return;
117
+ const path = resolve(paths.debug(), "context-snapshot.md");
118
+ try {
119
+ writeFileSync(path, content, "utf-8");
120
+ } catch {
121
+ /* non-critical */
122
+ }
123
+ }
124
+
93
125
  /** Log an error (always written, regardless of debug mode) */
94
126
  export function logError(source: string, error: unknown): void {
95
127
  const path = logFile();
@@ -60,6 +60,7 @@ export const paths = {
60
60
  synthesis: () => ensureDir(home("memory", "learning", "synthesis")),
61
61
  work: () => ensureDir(home("memory", "work")),
62
62
  backups: () => ensureDir(home("backups")),
63
+ debug: () => ensureDir(home("debug")),
63
64
  } as const;
64
65
 
65
66
  // Platform directories (env override or cross-platform defaults)
@@ -358,17 +358,6 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
358
358
  if (p.blockers?.length) {
359
359
  lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
360
360
  }
361
- if (p.criteria) {
362
- const openIscs = p.criteria
363
- .split("\n")
364
- .filter((l) => /^-\s+\[ \]\s+ISC-\d+:/i.test(l))
365
- .map((l) => l.replace(/^-\s+\[ \]\s+/, "").trim());
366
- if (openIscs.length > 0) {
367
- lines.push(
368
- ` Open ISCs (${openIscs.length}): ${openIscs.slice(0, MAX_INLINE_BULLETS).join("; ")}`
369
- );
370
- }
371
- }
372
361
  } else {
373
362
  const counts: string[] = [];
374
363
  if (p.next?.length) counts.push(`${p.next.length} next`);
@@ -24,19 +24,12 @@ const HOOK_MANAGED_FILES = [
24
24
  "captured-learnings.json",
25
25
  "counts.json",
26
26
  "session-names.json",
27
- "debug.log",
28
27
  "last-responses.json",
29
28
  "signal-cache.json",
30
29
  "pending-failure.json",
31
30
  "token-usage.jsonl",
32
31
  "graduated.json",
33
32
  "update-available.json",
34
- "debug.log.prev",
35
- "debug.log.1",
36
- "debug.log.2",
37
- "debug.log.3",
38
- "debug.log.4",
39
- "debug.log.5",
40
33
  "opinions.json",
41
34
  "pal-settings.json",
42
35
  "skill-index.json",
@@ -54,6 +47,7 @@ const HOOK_MANAGED_DIRS = [
54
47
  "memory/wisdom/state",
55
48
  "memory/projects",
56
49
  "memory/state/progress",
50
+ "debug",
57
51
  ];
58
52
 
59
53
  /** Escape a string for use in a RegExp */
@@ -6,9 +6,8 @@
6
6
  * and the session-stop digest writer.
7
7
  */
8
8
 
9
- import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { existsSync, readFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
- import { parse } from "./frontmatter";
12
11
  import { type FailureEntry, readFailures } from "./learning-store";
13
12
  import { loadOpinionContext } from "./opinions";
14
13
  import { palHome, paths } from "./paths";
@@ -47,59 +46,6 @@ function readFileSafe(path: string): string {
47
46
  }
48
47
  }
49
48
 
50
- /** Build recommendations from the most recent synthesis report. */
51
- export function loadSynthesisRecommendations(): string {
52
- try {
53
- const synthDir = paths.synthesis();
54
- if (!existsSync(synthDir)) return "";
55
-
56
- const months = readdirSync(synthDir).sort().reverse();
57
- for (const month of months) {
58
- const monthDir = resolve(synthDir, month);
59
- try {
60
- const files = readdirSync(monthDir)
61
- .filter((f) => f.endsWith(".md"))
62
- .sort()
63
- .reverse();
64
- if (files.length === 0) continue;
65
-
66
- const content = readFileSync(resolve(monthDir, files[0]), "utf-8");
67
-
68
- const recMatch = new RegExp(
69
- /## Recommendations\n\n([\s\S]*?)(?:\n##|\n$|$)/
70
- ).exec(content);
71
- if (!recMatch?.[1]?.trim()) continue;
72
-
73
- const recs = recMatch[1]
74
- .trim()
75
- .split("\n")
76
- .filter((l) => l.trim())
77
- .slice(0, 4);
78
-
79
- if (recs.length === 0) continue;
80
-
81
- const { meta } = parse<{ period?: string; average_rating?: string }>(content);
82
- const period = meta.period ?? "";
83
- const avgRating = meta.average_rating ? `${meta.average_rating}/10` : "";
84
-
85
- const header = [
86
- "## Pattern Synthesis",
87
- period ? `*${period} — ${avgRating}*` : "",
88
- ]
89
- .filter(Boolean)
90
- .join("\n");
91
-
92
- return [header, ...recs].join("\n");
93
- } catch {
94
- /* try next month */
95
- }
96
- }
97
- return "";
98
- } catch {
99
- return "";
100
- }
101
- }
102
-
103
49
  /**
104
50
  * Rank failures for injection: same-project (cwd match) first, then by recency.
105
51
  * Project-relevant lessons are never crowded out by more-recent other-project
@@ -186,13 +132,6 @@ export function getSemiStaticSources(): SemiStaticSource[] {
186
132
  slug: "opinions",
187
133
  description: "PAL opinions",
188
134
  },
189
- {
190
- path: resolve(memory, "learning", "synthesis-digest.md"),
191
- writesDigest: true,
192
- load: loadSynthesisRecommendations,
193
- slug: "synthesis",
194
- description: "PAL pattern synthesis",
195
- },
196
135
  {
197
136
  path: resolve(memory, "learning", "failures-digest.md"),
198
137
  writesDigest: true,
@@ -98,17 +98,34 @@ export function loadSettingsTemplate(templatePath: string, pkgRoot: string): Set
98
98
  export function mergeSettings(existing: Settings, template: Settings): Settings {
99
99
  const result = { ...existing };
100
100
 
101
- // Merge hooks (deduplicate by command)
101
+ // Merge hooks strip old-path PAL entries first, then insert current template entries.
102
+ // This handles reinstalling from a different path (repo vs global install) without
103
+ // leaving orphan hook entries that fire twice per event.
102
104
  if (template.hooks) {
103
105
  result.hooks ??= {};
104
- for (const [event, entries] of Object.entries(template.hooks)) {
105
- const current = result.hooks[event] ?? [];
106
+
107
+ // Collect canonical forms of all template hook commands
108
+ const palCanonical = new Set<string>();
109
+ for (const entries of Object.values(template.hooks)) {
106
110
  for (const entry of entries) {
107
111
  const cmd = entry.hooks?.[0]?.command;
108
- if (cmd && !current.some((e) => e.hooks?.[0]?.command === cmd)) {
109
- current.push(entry);
110
- }
112
+ if (cmd) palCanonical.add(canonicalPalCmd(cmd));
111
113
  }
114
+ }
115
+
116
+ // Strip existing PAL hooks that match canonically (removes old-path duplicates)
117
+ for (const [event, entries] of Object.entries(result.hooks)) {
118
+ result.hooks[event] = entries.filter((e) => {
119
+ const cmd = e.hooks?.[0]?.command;
120
+ return !cmd || !palCanonical.has(canonicalPalCmd(cmd));
121
+ });
122
+ if (result.hooks[event].length === 0) delete result.hooks[event];
123
+ }
124
+
125
+ // Insert template entries (old-path versions were just stripped)
126
+ for (const [event, entries] of Object.entries(template.hooks)) {
127
+ const current = result.hooks[event] ?? [];
128
+ for (const entry of entries) current.push(entry);
112
129
  result.hooks[event] = current;
113
130
  }
114
131
  }
@@ -195,20 +212,20 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
195
212
  export function unmergeSettings(existing: Settings, template: Settings): Settings {
196
213
  const result = { ...existing };
197
214
 
198
- // Collect all PAL hook commands from template
215
+ // Collect canonical PAL hook commands from template (path-normalized)
199
216
  if (template.hooks && result.hooks) {
200
- const palCommands = new Set<string>();
217
+ const palCanonical = new Set<string>();
201
218
  for (const entries of Object.values(template.hooks)) {
202
219
  for (const entry of entries) {
203
220
  const cmd = entry.hooks?.[0]?.command;
204
- if (cmd) palCommands.add(cmd);
221
+ if (cmd) palCanonical.add(canonicalPalCmd(cmd));
205
222
  }
206
223
  }
207
224
 
208
225
  for (const [event, entries] of Object.entries(result.hooks)) {
209
226
  result.hooks[event] = entries.filter((e) => {
210
227
  const cmd = e.hooks?.[0]?.command;
211
- return !cmd || !palCommands.has(cmd);
228
+ return !cmd || !palCanonical.has(canonicalPalCmd(cmd));
212
229
  });
213
230
  if (result.hooks[event].length === 0) delete result.hooks[event];
214
231
  }
@@ -377,9 +394,17 @@ type CodexHookCommand = { type: string; command: string; timeout?: number };
377
394
  type CodexHookGroup = { matcher?: string; hooks: CodexHookCommand[] };
378
395
  type CodexHooks = { hooks?: Record<string, CodexHookGroup[]> };
379
396
 
380
- /** Strip leading env-var assignments so "PAL_AGENT=x bun run ..." → "bun run ..." */
381
- function canonicalCmd(cmd: string): string {
382
- return cmd.replace(/^(?:\w+=\S+\s+)+/, "");
397
+ /**
398
+ * Normalize a PAL hook command for cross-path deduplication.
399
+ * Strips env-var prefix and normalizes the hook file path so reinstalling from
400
+ * a different location (repo vs global install) correctly strips old entries.
401
+ * "PAL_AGENT=x bun run /any/path/src/hooks/Foo.ts" → "bun run src/hooks/Foo.ts"
402
+ */
403
+ function canonicalPalCmd(cmd: string): string {
404
+ const withoutEnv = cmd.replace(/^(?:\w+=\S+\s+)+/, "");
405
+ const hookMatch = /bun\s+run\s+.+\/src\/hooks\/(\S+)/.exec(withoutEnv);
406
+ if (hookMatch) return `bun run src/hooks/${hookMatch[1]}`;
407
+ return withoutEnv;
383
408
  }
384
409
 
385
410
  export function loadCodexHooksTemplate(
@@ -399,7 +424,7 @@ export function loadCodexHooksTemplate(
399
424
  function collectPalCanonical(template: CodexHooks): Set<string> {
400
425
  return new Set(
401
426
  Object.values(template.hooks ?? {}).flatMap((groups) =>
402
- groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
427
+ groups.flatMap((g) => g.hooks.map((h) => canonicalPalCmd(h.command)))
403
428
  )
404
429
  );
405
430
  }
@@ -413,11 +438,11 @@ function stripPalHooks(
413
438
  hooks[event] = (hooks[event] ?? [])
414
439
  .map((g) => {
415
440
  const flat = g as unknown as CodexHookCommand;
416
- if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
441
+ if (!g.hooks && flat.command && palCanonical.has(canonicalPalCmd(flat.command))) {
417
442
  return null;
418
443
  }
419
444
  const filtered = (g.hooks ?? []).filter(
420
- (h) => !palCanonical.has(canonicalCmd(h.command))
445
+ (h) => !palCanonical.has(canonicalPalCmd(h.command))
421
446
  );
422
447
  return filtered.length > 0 ? { ...g, hooks: filtered } : null;
423
448
  })
@@ -30,7 +30,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
30
30
  await lib<typeof import("../../hooks/lib/context")>("context.ts");
31
31
  const { checkBashCommand, checkFilePath } =
32
32
  await lib<typeof import("../../hooks/lib/security")>("security.ts");
33
- const { logDebug, logError } =
33
+ const { logDebug, logError, logPromptSnapshot } =
34
34
  await lib<typeof import("../../hooks/lib/log")>("log.ts");
35
35
 
36
36
  // Load shared handlers
@@ -139,19 +139,21 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
139
139
  const text = partsToText(output.parts ?? []);
140
140
  if (!text.trim()) return;
141
141
 
142
- const [, , retrievalResult] = await Promise.allSettled([
142
+ const retrieval = await getRetrievalReminder(text);
143
+ logPromptSnapshot(text, retrieval);
144
+
145
+ await Promise.allSettled([
143
146
  captureRating(text, input.sessionID),
144
147
  captureSessionName(text, input.sessionID),
145
- getRetrievalReminder(text),
146
148
  ]);
147
149
 
148
- if (retrievalResult.status === "fulfilled" && retrievalResult.value) {
150
+ if (retrieval) {
149
151
  const injected = {
150
152
  id: `pal-retrieval-${Date.now()}`,
151
153
  sessionID: input.sessionID,
152
154
  messageID: input.messageID ?? `pal-msg-${Date.now()}`,
153
155
  type: "text" as const,
154
- text: retrievalResult.value,
156
+ text: retrieval,
155
157
  synthetic: true,
156
158
  };
157
159
  output.parts = [injected, ...(output.parts ?? [])];
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { parseArgs } from "node:util";
12
+ import { writeLastAnalyzeDate } from "../../hooks/lib/analyze-nudge";
12
13
  import { type AnalysisResult, analyze } from "../../hooks/lib/graduation";
13
14
 
14
15
  // ── ANSI Colors ──
@@ -23,7 +24,7 @@ const c = {
23
24
  magenta: (s: string) => `\x1b[35m${s}\x1b[0m`,
24
25
  };
25
26
 
26
- export function printReport(result: AnalysisResult): void {
27
+ function printReport(result: AnalysisResult): void {
27
28
  const hasPatterns = result.candidates.length > 0 || result.emerging.length > 0;
28
29
  const hasRatings = result.ratings !== null;
29
30
 
@@ -117,9 +118,9 @@ export function printReport(result: AnalysisResult): void {
117
118
  }
118
119
  }
119
120
 
120
- async function run() {
121
+ export async function run(argv: string[] = Bun.argv.slice(2)) {
121
122
  const { values } = parseArgs({
122
- args: Bun.argv.slice(2),
123
+ args: argv,
123
124
  options: {
124
125
  help: { type: "boolean", short: "h" },
125
126
  actionable: { type: "boolean", short: "a" },
@@ -145,13 +146,14 @@ async function run() {
145
146
  To crystallize a graduated pattern, add it to the target wisdom frame:
146
147
  - Your principle here [CRYSTAL: 85%]
147
148
 
148
- Usage: bun run tool:analyze [--actionable] [--help]
149
+ Usage: pal cli analyze [--actionable]
149
150
  `);
150
151
  process.exit(0);
151
152
  }
152
153
 
153
154
  const result = await analyze({ actionable: values.actionable });
154
155
  printReport(result);
156
+ writeLastAnalyzeDate(new Date().toISOString());
155
157
  }
156
158
 
157
159
  if (import.meta.main) await run();
@@ -1,12 +1,19 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * RelationshipNote — Write a B entry to today's relationship log.
3
+ * RelationshipNote — Write W/O/Session entries to today's relationship log.
4
4
  *
5
- * Called in the ALGORITHM LEARN phase. Claude writes the B entry directly
6
- * from full session context no inference call needed.
5
+ * Called in the ALGORITHM LEARN phase. Writes behavioral observations about
6
+ * the user (O, W) and session diary entries (--b).
7
7
  *
8
8
  * Usage:
9
- * bun ~/.pal/tools/relationship-note.ts --b "what I did this session"
9
+ * bun ~/.pal/tools/relationship-note.ts --o "Rico prefers X" --confidence 0.80
10
+ * bun ~/.pal/tools/relationship-note.ts --w "Rico is building X in TypeScript"
11
+ * bun ~/.pal/tools/relationship-note.ts --b "Debugged the cache split logic"
12
+ *
13
+ * Note types:
14
+ * --o Opinion/behavioral observation about the user (requires --confidence)
15
+ * --w World fact about the user's situation (objective, observable)
16
+ * --b Session diary — what Jarvis did this session (first-person, specific)
10
17
  */
11
18
 
12
19
  import { parseArgs } from "node:util";
@@ -16,35 +23,72 @@ function run() {
16
23
  const { values } = parseArgs({
17
24
  args: Bun.argv.slice(2),
18
25
  options: {
26
+ o: { type: "string", multiple: true },
27
+ w: { type: "string", multiple: true },
19
28
  b: { type: "string" },
29
+ confidence: { type: "string" },
20
30
  help: { type: "boolean", short: "h" },
21
31
  },
22
32
  });
23
33
 
24
34
  if (values.help) {
25
35
  console.log(`
26
- RelationshipNote — Append a B entry to today's relationship log
36
+ RelationshipNote — Append W/O/Session entries to today's relationship log
27
37
 
28
38
  Usage:
29
- bun ~/.pal/tools/relationship-note.ts --b "description"
39
+ bun ~/.pal/tools/relationship-note.ts --o "Rico prefers X" --confidence 0.80
40
+ bun ~/.pal/tools/relationship-note.ts --w "Rico is building X in TypeScript"
41
+ bun ~/.pal/tools/relationship-note.ts --b "Debugged the cache split logic"
42
+
43
+ Flags:
44
+ --o TEXT Opinion/behavioral observation about the user
45
+ --confidence N Confidence for --o (0.0–1.0, default 0.75)
46
+ --w TEXT World fact about the user's situation
47
+ --b TEXT Session diary — what Jarvis did (first-person, specific)
30
48
 
31
- Arguments:
32
- --b What happened this session (1-2 sentences, first-person, specific)
49
+ Multiple flags may be combined in one call. At least one of --o, --w, --b is required.
33
50
 
34
51
  Output: appends to memory/relationship/YYYY-MM/YYYY-MM-DD.md
35
52
  `);
36
53
  process.exit(0);
37
54
  }
38
55
 
39
- if (!values.b) {
40
- console.error("Required: --b");
56
+ if (!values.o && !values.w && !values.b) {
57
+ console.error("Required: at least one of --o, --w, --b");
41
58
  process.exit(1);
42
59
  }
43
60
 
44
- appendNotes([{ type: "Session", text: values.b }]);
61
+ const notes = [];
62
+
63
+ if (values.o && values.o.length > 0) {
64
+ const confidence = values.confidence ? Number.parseFloat(values.confidence) : 0.75;
65
+ if (Number.isNaN(confidence) || confidence < 0 || confidence > 1) {
66
+ console.error("--confidence must be a number between 0.0 and 1.0");
67
+ process.exit(1);
68
+ }
69
+ for (const text of values.o) {
70
+ notes.push({ type: "O" as const, text, confidence });
71
+ }
72
+ }
73
+
74
+ if (values.w && values.w.length > 0) {
75
+ for (const text of values.w) {
76
+ notes.push({ type: "W" as const, text });
77
+ }
78
+ }
79
+
80
+ if (values.b) {
81
+ notes.push({ type: "Session" as const, text: values.b });
82
+ }
83
+
84
+ appendNotes(notes);
45
85
 
46
86
  console.log(
47
- JSON.stringify({ success: true, message: "Relationship note written" }, null, 2)
87
+ JSON.stringify(
88
+ { success: true, message: "Relationship note written", count: notes.length },
89
+ null,
90
+ 2
91
+ )
48
92
  );
49
93
  }
50
94
 
@@ -242,9 +242,49 @@ function getRecentSessions(since: Date): {
242
242
  export function writeSynthesis(state: SynthesisState): string {
243
243
  const sp = synthesisPath();
244
244
  writeFileSync(sp, JSON.stringify(state, null, 2), "utf-8");
245
+ writeSignalCache();
245
246
  return sp;
246
247
  }
247
248
 
249
+ function writeSignalCache(): void {
250
+ try {
251
+ const p = resolve(paths.signals(), "ratings.jsonl");
252
+ const all = readJsonl<Rating>(p);
253
+ const now = new Date();
254
+ const todayStr = now.toISOString().slice(0, 10);
255
+
256
+ const avg = (rs: Rating[]) =>
257
+ rs.length === 0 ? null : round1(rs.reduce((s, r) => s + r.rating, 0) / rs.length);
258
+
259
+ const today = avg(all.filter((r) => r.ts.slice(0, 10) === todayStr));
260
+ const week = avg(all.filter((r) => new Date(r.ts) >= daysAgo(7)));
261
+ const month = avg(all.filter((r) => new Date(r.ts) >= daysAgo(30)));
262
+
263
+ const recent = all.slice(-20);
264
+ const mid = Math.floor(recent.length / 2);
265
+ let trend: "up" | "down" | "stable" = "stable";
266
+ if (mid >= 3) {
267
+ const a = avg(recent.slice(0, mid)) ?? 0;
268
+ const b = avg(recent.slice(mid)) ?? 0;
269
+ if (b - a > 0.5) trend = "up";
270
+ else if (a - b > 0.5) trend = "down";
271
+ }
272
+
273
+ const cache = resolve(paths.state(), "signal-cache.json");
274
+ writeFileSync(
275
+ cache,
276
+ JSON.stringify(
277
+ { computed_at: now.toISOString(), today, week, month, trend },
278
+ null,
279
+ 2
280
+ ),
281
+ "utf-8"
282
+ );
283
+ } catch {
284
+ /* non-critical */
285
+ }
286
+ }
287
+
248
288
  export function synthesize(days: number): SynthesisState {
249
289
  const since = daysAgo(days);
250
290
  const { sessions, count: sessionCount } = getRecentSessions(since);