portable-agent-layer 0.79.1 → 0.79.3

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.
@@ -58,9 +58,7 @@
58
58
  "opinions": { "type": "boolean", "description": "Tracked user opinions." },
59
59
  "relationship": { "type": "boolean", "description": "Recent relationship notes." },
60
60
  "failurePatterns": { "type": "boolean", "description": "Recurring failure patterns." },
61
- "projectHistory": { "type": "boolean", "description": "Per-project session history." },
62
61
  "projects": { "type": "boolean", "description": "Active projects list." },
63
- "sessionIntelligence": { "type": "boolean", "description": "Rating trends and session signals." },
64
62
  "handoff": { "type": "boolean", "description": "Handoff note from the previous session." },
65
63
  "selfModel": { "type": "boolean", "description": "The assistant's self-model synthesis." },
66
64
  "contextualSteering": { "type": "boolean", "description": "Prompt-time steering self-checks (see the steering section)." },
@@ -20,9 +20,7 @@
20
20
  "opinions": true,
21
21
  "relationship": true,
22
22
  "failurePatterns": true,
23
- "projectHistory": true,
24
23
  "projects": true,
25
- "sessionIntelligence": true,
26
24
  "handoff": true,
27
25
  "selfModel": true,
28
26
  "contextualSteering": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.79.1",
3
+ "version": "0.79.3",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,8 +71,20 @@ function readHandoffs(): Record<string, StoredHandoff> {
71
71
  }
72
72
  }
73
73
 
74
+ function isWithinReadWindow(entry: StoredHandoff): boolean {
75
+ if (!entry.timestamp) return false;
76
+ return Date.now() - new Date(entry.timestamp).getTime() <= HANDOFF_STALE_MS;
77
+ }
78
+
74
79
  function writeHandoffs(handoffs: Record<string, StoredHandoff>): void {
75
- writeFileSync(handoffPath(), JSON.stringify(handoffs, null, 2), "utf-8");
80
+ const readable = Object.entries(handoffs).filter(([, entry]) =>
81
+ isWithinReadWindow(entry)
82
+ );
83
+ writeFileSync(
84
+ handoffPath(),
85
+ JSON.stringify(Object.fromEntries(readable), null, 2),
86
+ "utf-8"
87
+ );
76
88
  }
77
89
 
78
90
  export function structuredHandoff(parts: {
@@ -13,6 +13,8 @@ import { existsSync } from "node:fs";
13
13
  import { unlink, writeFile } from "node:fs/promises";
14
14
  import { resolve } from "node:path";
15
15
  import {
16
+ claimHandoffSummary,
17
+ isHandoffStale,
16
18
  isRecaptureWorthwhile,
17
19
  learningSlug,
18
20
  markCaptured,
@@ -109,9 +111,10 @@ export async function captureSessionIntelligence(
109
111
  const messages = parseMessages(transcript);
110
112
  if (messages.length < 6 || transcript.length < 2000) return;
111
113
 
112
- if (sessionId && !isRecaptureWorthwhile(readCapture(sessionId), messages.length)) {
113
- return;
114
- }
114
+ const userMessageCount = messages.filter((m) => m.role === "user").length;
115
+ const previous = sessionId ? readCapture(sessionId) : null;
116
+ const learningDue = isRecaptureWorthwhile(previous, messages.length);
117
+ if (!learningDue && !isHandoffStale(previous, userMessageCount)) return;
115
118
 
116
119
  // Skip if no inference path is available (no CLI binary AND no API key)
117
120
  if (!canInfer()) {
@@ -134,6 +137,8 @@ export async function captureSessionIntelligence(
134
137
 
135
138
  if (userWindow.length < 3) return;
136
139
 
140
+ if (sessionId) claimHandoffSummary(sessionId, userMessageCount);
141
+
137
142
  // Single Haiku call
138
143
  logDebug("session-intelligence", "Calling inference...");
139
144
  const numberedMessages = userWindow.map((m, i) => `${i + 1}. ${m}`).join("\n");
@@ -179,6 +184,8 @@ export async function captureSessionIntelligence(
179
184
  sessionId,
180
185
  });
181
186
  }
187
+ if (!learningDue) return;
188
+
182
189
  // ── Write session learning file ──
183
190
 
184
191
  const category = categorizeLearning(title, summary);
@@ -219,13 +226,15 @@ export async function captureSessionIntelligence(
219
226
  const filepath = resolve(dir, filename);
220
227
  await writeFile(filepath, content, "utf-8");
221
228
 
222
- // Append to per-project history
223
- appendProjectHistory(process.cwd(), {
224
- date: new Date().toISOString().slice(0, 10),
225
- title,
226
- summary,
227
- insights,
228
- });
229
+ if (output) {
230
+ appendProjectHistory(sessionDir(), {
231
+ date: new Date().toISOString().slice(0, 10),
232
+ title,
233
+ summary,
234
+ insights,
235
+ session: sessionId,
236
+ });
237
+ }
229
238
 
230
239
  if (sessionId) markCaptured(sessionId, filepath, messages.length);
231
240
  logDebug("session-intelligence", `Learning captured: ${title}`);
@@ -13,6 +13,7 @@ import { paths } from "./paths";
13
13
  export interface CaptureEntry {
14
14
  filepath: string;
15
15
  messageCount: number;
16
+ userMessageCount?: number;
16
17
  }
17
18
 
18
19
  /** Below this many new messages, a re-capture would restate what was already written. */
@@ -71,13 +72,22 @@ export function isRecaptureWorthwhile(
71
72
  return messageCount - previous.messageCount >= MIN_NEW_MESSAGES;
72
73
  }
73
74
 
74
- export function markCaptured(
75
- sessionId: string,
76
- filepath: string,
77
- messageCount: number
78
- ): void {
75
+ /**
76
+ * The handoff describes the latest turn, so any new message from the user makes
77
+ * it stale, however little the transcript grew.
78
+ */
79
+ export function isHandoffStale(
80
+ previous: CaptureEntry | null,
81
+ userMessageCount: number
82
+ ): boolean {
83
+ if (!previous) return true;
84
+ return userMessageCount > (previous.userMessageCount ?? 0);
85
+ }
86
+
87
+ function updateCapture(sessionId: string, update: Partial<CaptureEntry>): void {
79
88
  const data = readAll();
80
- data[sessionId] = { filepath, messageCount };
89
+ const current = data[sessionId] ?? { filepath: "", messageCount: 0 };
90
+ data[sessionId] = { ...current, ...update };
81
91
  const entries = Object.entries(data);
82
92
  const kept = entries.length > MAX_REMEMBERED ? entries.slice(-MAX_REMEMBERED) : entries;
83
93
  writeFileSync(
@@ -87,6 +97,19 @@ export function markCaptured(
87
97
  );
88
98
  }
89
99
 
100
+ export function markCaptured(
101
+ sessionId: string,
102
+ filepath: string,
103
+ messageCount: number
104
+ ): void {
105
+ updateCapture(sessionId, { filepath, messageCount });
106
+ }
107
+
108
+ /** Claimed before the model call, so a second Stop for the same turn finds it taken. */
109
+ export function claimHandoffSummary(sessionId: string, userMessageCount: number): void {
110
+ updateCapture(sessionId, { userMessageCount });
111
+ }
112
+
90
113
  /**
91
114
  * The readable half of a learning file's name. Four words, because the rest of
92
115
  * the name is already a timestamp and a category and the whole thing has to stay
@@ -9,6 +9,7 @@ import { loadReflectNudge } from "../handlers/reflect-trigger";
9
9
  import { loadAlgorithmReviewNudge } from "./algorithm-review";
10
10
  import { loadAnalyzeNudge } from "./analyze-nudge";
11
11
  import { resolveAnchor } from "./anchor";
12
+ import { oncePerDay } from "./daily-nudge";
12
13
  import { loadHandoffContext } from "./handoff-context";
13
14
  import { loadOpinionContext } from "./opinions";
14
15
  import { paths, toPath } from "./paths";
@@ -18,7 +19,7 @@ import { loadFailurePatterns } from "./semi-static";
18
19
  import { sessionDir } from "./session-dir";
19
20
  import * as settings from "./settings";
20
21
  import { readFramePrinciples } from "./wisdom";
21
- import { lastSessionTitle, readProjectHistory } from "./work-tracking";
22
+ import { lastSessionTitle } from "./work-tracking";
22
23
 
23
24
  /** Load and concatenate loadAtStartup files */
24
25
  function loadStartupFiles(): string {
@@ -65,25 +66,6 @@ function loadSelfModel(): string {
65
66
  }
66
67
  }
67
68
 
68
- /** Load per-project session history for the current working directory */
69
- function loadProjectHistoryContext(): string {
70
- try {
71
- const cwd = process.cwd();
72
- const entries = readProjectHistory(cwd, 3);
73
- if (entries.length === 0) return "";
74
-
75
- const lines: string[] = ["## This Project — Session History"];
76
- for (const e of entries) {
77
- lines.push(`- **${e.title}** (${e.date})`);
78
- if (e.summary) lines.push(` ${e.summary.split("\n")[0].slice(0, 150)}`);
79
- }
80
-
81
- return lines.join("\n");
82
- } catch {
83
- return "";
84
- }
85
- }
86
-
87
69
  function stampedPath(stamp: string): string {
88
70
  const resolved = resolveAnchor(stamp);
89
71
  return resolved.state === "unresolvable" ? stamp : resolved.path;
@@ -135,8 +117,11 @@ export function loadRelationshipContext(): string {
135
117
  }
136
118
  }
137
119
 
138
- /** Load session intelligence from compact synthesis state */
139
- function loadSessionIntelligence(): string {
120
+ /**
121
+ * Off the startup context until the reflection stats are reliable enough to act on.
122
+ * @lintignore exercised directly by test/context-build.test.ts
123
+ */
124
+ export function loadSessionIntelligence(): string {
140
125
  try {
141
126
  const p = resolve(paths.state(), "synthesis.json");
142
127
  if (!existsSync(p)) return "";
@@ -144,28 +129,6 @@ function loadSessionIntelligence(): string {
144
129
 
145
130
  const lines: string[] = ["## Session Intelligence"];
146
131
 
147
- // Rating Trend
148
- if (state.ratings?.count > 0) {
149
- const r = state.ratings;
150
- const lowNote = r.lowCount > 0 ? ` ${r.lowCount} low ratings.` : "";
151
- lines.push(
152
- "",
153
- `**Rating trend:** ${r.avg}/10 avg (last 10: ${r.recentAvg}/10, ${r.trend}).${lowNote}`
154
- );
155
- if (r.trend === "declining") {
156
- lines.push(
157
- "→ Trend is declining. Be extra careful with assumptions. Confirm before acting."
158
- );
159
- } else if (r.trend === "improving") {
160
- lines.push("→ Trend is improving. Maintain current approach.");
161
- } else if (r.lowCount > 5) {
162
- lines.push(
163
- "→ Multiple low ratings. Slow down, verify before acting, ask when uncertain."
164
- );
165
- }
166
- }
167
-
168
- // Algorithm Performance
169
132
  if (state.algorithm?.reflectionCount > 0) {
170
133
  const a = state.algorithm;
171
134
  lines.push(
@@ -242,11 +205,8 @@ export function buildSystemReminder(
242
205
  const relationship = settings.isEnabled("relationship")
243
206
  ? loadRelationshipContext()
244
207
  : "";
245
- const projectHistory = settings.isEnabled("projectHistory")
246
- ? loadProjectHistoryContext()
247
- : "";
248
208
  const activeProjects = settings.isEnabled("projects")
249
- ? loadActiveProjectsContext(process.cwd(), lastSessionTitle)
209
+ ? loadActiveProjectsContext(sessionDir(), lastSessionTitle)
250
210
  : "";
251
211
  const failures =
252
212
  settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
@@ -254,17 +214,14 @@ export function buildSystemReminder(
254
214
  !skipSemiStatic && settings.isEnabled("opinions") ? loadOpinionContext() : "";
255
215
  const selfModel =
256
216
  !skipSemiStatic && settings.isEnabled("selfModel") ? loadSelfModel() : "";
257
- const intelligence = settings.isEnabled("sessionIntelligence")
258
- ? loadSessionIntelligence()
259
- : "";
260
217
  const handoff =
261
218
  !opts.withoutHandoff && settings.isEnabled("handoff")
262
219
  ? loadHandoffContext(sessionDir())
263
220
  : "";
264
221
  // Maintainer-only: self-gates to a repo checkout, "" for everyone else.
265
- const algoReview = loadAlgorithmReviewNudge();
266
- const reflectNudge = loadReflectNudge();
267
- const analyzeNudge = loadAnalyzeNudge();
222
+ const algoReview = oncePerDay("algorithm-review", loadAlgorithmReviewNudge());
223
+ const reflectNudge = oncePerDay("reflect", loadReflectNudge());
224
+ const analyzeNudge = oncePerDay("analyze", loadAnalyzeNudge());
268
225
  const parts: string[] = [];
269
226
  if (startup) parts.push(startup);
270
227
  if (handoff) parts.push(handoff);
@@ -274,10 +231,8 @@ export function buildSystemReminder(
274
231
  if (selfModel) parts.push(selfModel);
275
232
  if (wisdom) parts.push(wisdom);
276
233
  if (opinions) parts.push(opinions);
277
- if (intelligence) parts.push(intelligence);
278
234
  if (relationship) parts.push(relationship);
279
235
  if (activeProjects) parts.push(activeProjects);
280
- if (projectHistory) parts.push(projectHistory);
281
236
  if (failures) parts.push(failures);
282
237
  if (parts.length === 0) return "";
283
238
 
@@ -0,0 +1,35 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { ensureDir, paths } from "./paths";
4
+ import { localDay } from "./wall-clock";
5
+
6
+ type ShownOn = Record<string, string>;
7
+
8
+ function shownPath(): string {
9
+ return resolve(ensureDir(paths.state()), "nudges-shown.json");
10
+ }
11
+
12
+ function readShownOn(): ShownOn {
13
+ try {
14
+ return JSON.parse(readFileSync(shownPath(), "utf-8"));
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ /**
21
+ * A reminder asks the user for something, so it belongs in the first session
22
+ * of the day, not in every session, resume and compaction after it.
23
+ */
24
+ export function oncePerDay(
25
+ key: string,
26
+ reminder: string,
27
+ now: Date = new Date()
28
+ ): string {
29
+ if (!reminder) return "";
30
+ const today = localDay(now);
31
+ const shownOn = readShownOn();
32
+ if (shownOn[key] === today) return "";
33
+ writeFileSync(shownPath(), JSON.stringify({ ...shownOn, [key]: today }), "utf-8");
34
+ return reminder;
35
+ }
@@ -386,6 +386,27 @@ function formatAgo(ts: string): string {
386
386
  }
387
387
 
388
388
  const MAX_INLINE_BULLETS = 3;
389
+ const FACTS_BUDGET = 600;
390
+
391
+ function factLines(context: string): string[] {
392
+ return context
393
+ .split("\n")
394
+ .map((l) => l.replace(/^[-*]\s*/, "").trim())
395
+ .filter((l) => l && !l.startsWith("#"));
396
+ }
397
+
398
+ /** Whole facts in written order, so none is cut mid-sentence; the first always fits. */
399
+ function factsLine(project: string, context: string): string {
400
+ const facts = factLines(context);
401
+ const shown = facts.slice(0, 1);
402
+ for (const fact of facts.slice(1)) {
403
+ if ([...shown, fact].join("; ").length > FACTS_BUDGET) break;
404
+ shown.push(fact);
405
+ }
406
+ const more =
407
+ shown.length < facts.length ? ` (more: \`pal cli project resume ${project}\`)` : "";
408
+ return ` Facts: ${shown.join("; ")}${more}`;
409
+ }
389
410
 
390
411
  /**
391
412
  * Format the SessionStart "Active Projects" section.
@@ -425,13 +446,7 @@ export function loadActiveProjectsContext(
425
446
 
426
447
  if (isResolved) {
427
448
  lines.push(`- **${p.name}** (${statusPrefix}${ago})${stale}${here}`);
428
- if (p.context) {
429
- const bullets = p.context
430
- .split("\n")
431
- .map((l) => l.replace(/^[-*]\s*/, "").trim())
432
- .filter(Boolean);
433
- lines.push(` Facts: ${bullets.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
434
- }
449
+ if (p.context) lines.push(factsLine(p.name, p.context));
435
450
  if (p.goal) {
436
451
  const bullets = p.goal
437
452
  .split("\n")
@@ -22,7 +22,12 @@ import { captureWorkSession } from "../handlers/work-session";
22
22
  import { spawnDetachedInference } from "./detached-inference";
23
23
  import { logDebug, logError } from "./log";
24
24
  import { assets, ensureDir, paths } from "./paths";
25
- import { extractContent, extractLastAssistant, parseMessages } from "./transcript";
25
+ import {
26
+ extractContent,
27
+ extractLastAssistant,
28
+ parseMessages,
29
+ withFinalReply,
30
+ } from "./transcript";
26
31
 
27
32
  interface RunStopHandlersOptions {
28
33
  lastAssistantMessage?: string;
@@ -31,11 +36,13 @@ interface RunStopHandlersOptions {
31
36
 
32
37
  /** Run all stop handlers with a transcript string */
33
38
  export async function runStopHandlers(
34
- transcript: string,
39
+ fileTranscript: string,
35
40
  options: RunStopHandlersOptions = {}
36
41
  ): Promise<void> {
37
- const messages = parseMessages(transcript);
38
- if (messages.length < 2) return;
42
+ const fileMessages = parseMessages(fileTranscript);
43
+ if (fileMessages.length < 2) return;
44
+ const messages = withFinalReply(fileMessages, options.lastAssistantMessage);
45
+ const transcript = JSON.stringify(messages);
39
46
 
40
47
  logDebug("runStopHandlers", `Running handlers (${messages.length} messages)`);
41
48
 
@@ -89,6 +89,15 @@ export function extractLastAssistant(messages: Message[]): Message | undefined {
89
89
  return messages.filter((m) => m.role === "assistant").pop();
90
90
  }
91
91
 
92
+ /** The agent hands its final reply to the Stop hook before that reply reaches the transcript file. */
93
+ export function withFinalReply(messages: Message[], finalReply?: string): Message[] {
94
+ const reply = finalReply?.trim();
95
+ if (!reply) return messages;
96
+ const last = messages.at(-1);
97
+ if (last?.role === "assistant" && extractContent(last).includes(reply)) return messages;
98
+ return [...messages, { role: "assistant", content: reply }];
99
+ }
100
+
92
101
  /** Get the last user message from a messages array */
93
102
  export function extractLastUser(messages: Message[]): Message | undefined {
94
103
  return messages.filter((m) => m.role === "user").pop();
@@ -51,6 +51,12 @@ export function wallClockLine(now: Date, configuredZone: string): string {
51
51
  return `Now: ${f.weekday} ${f.year}-${f.month}-${f.day} ${f.hour}:${f.minute} ${zone}`;
52
52
  }
53
53
 
54
+ /** The calendar date in the principal's timezone, YYYY-MM-DD. */
55
+ export function localDay(now: Date = new Date()): string {
56
+ const f = fieldsIn(now, zoneOrUtc(identity().principal.timezone));
57
+ return `${f.year}-${f.month}-${f.day}`;
58
+ }
59
+
54
60
  export function getWallClockReminder(now: Date = new Date()): string | null {
55
61
  if (!isEnabled("wallClock")) return null;
56
62
  const line = wallClockLine(now, identity().principal.timezone);
@@ -145,6 +145,7 @@ interface ProjectHistoryEntry {
145
145
  title: string;
146
146
  summary: string;
147
147
  insights: string;
148
+ session?: string;
148
149
  }
149
150
 
150
151
  /**
@@ -160,11 +161,28 @@ function historyFileFor(cwd: string): string {
160
161
  : resolve(paths.unboundHistory(), `${defaultSlug(cwd)}.jsonl`);
161
162
  }
162
163
 
163
- /** Append a learning entry to the history file owning this cwd */
164
+ function withoutSession(lines: string[], session: string): string[] {
165
+ return lines.filter((line) => {
166
+ try {
167
+ return (JSON.parse(line) as ProjectHistoryEntry).session !== session;
168
+ } catch {
169
+ return true;
170
+ }
171
+ });
172
+ }
173
+
174
+ /** A session is summarised again as it grows; its entry is replaced, not repeated. */
164
175
  export function appendProjectHistory(cwd: string, entry: ProjectHistoryEntry): void {
165
176
  const historyPath = historyFileFor(cwd);
166
177
  ensureDir(dirname(historyPath));
167
- appendFileSync(historyPath, `${JSON.stringify(entry)}\n`, "utf-8");
178
+ const line = JSON.stringify(entry);
179
+ if (!entry.session || !existsSync(historyPath)) {
180
+ appendFileSync(historyPath, `${line}\n`, "utf-8");
181
+ return;
182
+ }
183
+ const lines = readFileSync(historyPath, "utf-8").split("\n").filter(Boolean);
184
+ const kept = withoutSession(lines, entry.session);
185
+ writeFileSync(historyPath, `${[...kept, line].join("\n")}\n`, "utf-8");
168
186
  }
169
187
 
170
188
  /** Read the session history for a given cwd */