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.
- package/assets/schema/pal-settings.schema.json +1 -2
- package/assets/templates/pal-settings.json +0 -1
- package/package.json +1 -1
- package/src/hooks/handlers/session-intelligence.ts +18 -10
- package/src/hooks/lib/capture-store.ts +29 -6
- package/src/hooks/lib/context.ts +6 -50
- package/src/hooks/lib/daily-nudge.ts +35 -0
- package/src/hooks/lib/projects.ts +22 -7
- package/src/hooks/lib/wall-clock.ts +6 -0
|
@@ -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": "
|
|
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)." },
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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]
|
|
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
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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);
|