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.
- package/assets/schema/pal-settings.schema.json +0 -2
- package/assets/templates/pal-settings.json +0 -2
- package/package.json +1 -1
- package/src/hooks/handlers/persist-last-exchange.ts +13 -1
- package/src/hooks/handlers/session-intelligence.ts +19 -10
- package/src/hooks/lib/capture-store.ts +29 -6
- package/src/hooks/lib/context.ts +11 -56
- package/src/hooks/lib/daily-nudge.ts +35 -0
- package/src/hooks/lib/projects.ts +22 -7
- package/src/hooks/lib/stop.ts +11 -4
- package/src/hooks/lib/transcript.ts +9 -0
- package/src/hooks/lib/wall-clock.ts +6 -0
- package/src/hooks/lib/work-tracking.ts +20 -2
|
@@ -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)." },
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,15 @@ 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
|
+
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
|
-
|
|
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;
|
|
@@ -135,8 +117,11 @@ export function loadRelationshipContext(): string {
|
|
|
135
117
|
}
|
|
136
118
|
}
|
|
137
119
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
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(
|
|
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")
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
39
|
+
fileTranscript: string,
|
|
35
40
|
options: RunStopHandlersOptions = {}
|
|
36
41
|
): Promise<void> {
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 */
|