portable-agent-layer 0.79.1 → 0.79.2

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,8 @@
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." },
62
+ "sessionIntelligence": { "type": "boolean", "description": "Algorithm reflection stats and this project's self-observations." },
64
63
  "handoff": { "type": "boolean", "description": "Handoff note from the previous session." },
65
64
  "selfModel": { "type": "boolean", "description": "The assistant's self-model synthesis." },
66
65
  "contextualSteering": { "type": "boolean", "description": "Prompt-time steering self-checks (see the steering section)." },
@@ -20,7 +20,6 @@
20
20
  "opinions": true,
21
21
  "relationship": true,
22
22
  "failurePatterns": true,
23
- "projectHistory": true,
24
23
  "projects": true,
25
24
  "sessionIntelligence": true,
26
25
  "handoff": 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.2",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,14 @@ 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
+ });
236
+ }
229
237
 
230
238
  if (sessionId) markCaptured(sessionId, filepath, messages.length);
231
239
  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;
@@ -144,28 +126,6 @@ function loadSessionIntelligence(): string {
144
126
 
145
127
  const lines: string[] = ["## Session Intelligence"];
146
128
 
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
129
  if (state.algorithm?.reflectionCount > 0) {
170
130
  const a = state.algorithm;
171
131
  lines.push(
@@ -242,11 +202,8 @@ export function buildSystemReminder(
242
202
  const relationship = settings.isEnabled("relationship")
243
203
  ? loadRelationshipContext()
244
204
  : "";
245
- const projectHistory = settings.isEnabled("projectHistory")
246
- ? loadProjectHistoryContext()
247
- : "";
248
205
  const activeProjects = settings.isEnabled("projects")
249
- ? loadActiveProjectsContext(process.cwd(), lastSessionTitle)
206
+ ? loadActiveProjectsContext(sessionDir(), lastSessionTitle)
250
207
  : "";
251
208
  const failures =
252
209
  settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
@@ -262,9 +219,9 @@ export function buildSystemReminder(
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);
@@ -277,7 +234,6 @@ export function buildSystemReminder(
277
234
  if (intelligence) parts.push(intelligence);
278
235
  if (relationship) parts.push(relationship);
279
236
  if (activeProjects) parts.push(activeProjects);
280
- if (projectHistory) parts.push(projectHistory);
281
237
  if (failures) parts.push(failures);
282
238
  if (parts.length === 0) return "";
283
239
 
@@ -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")
@@ -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);