portable-agent-layer 0.78.3 → 0.79.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.
@@ -57,7 +57,6 @@
57
57
  "wisdom": { "type": "boolean", "description": "Crystallized wisdom principles." },
58
58
  "opinions": { "type": "boolean", "description": "Tracked user opinions." },
59
59
  "relationship": { "type": "boolean", "description": "Recent relationship notes." },
60
- "learningDigest": { "type": "boolean", "description": "Lessons-from-failures digest." },
61
60
  "failurePatterns": { "type": "boolean", "description": "Recurring failure patterns." },
62
61
  "projectHistory": { "type": "boolean", "description": "Per-project session history." },
63
62
  "projects": { "type": "boolean", "description": "Active projects list." },
@@ -19,7 +19,6 @@
19
19
  "wisdom": true,
20
20
  "opinions": true,
21
21
  "relationship": true,
22
- "learningDigest": true,
23
22
  "failurePatterns": true,
24
23
  "projectHistory": true,
25
24
  "projects": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.78.3",
3
+ "version": "0.79.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,11 +18,13 @@ import { logContextSnapshot, logDebug, logError } from "./lib/log";
18
18
  import { platform } from "./lib/paths";
19
19
  import {
20
20
  contextEnvelope,
21
+ contextForSource,
21
22
  copilotInstructions,
22
23
  isSubagentSession,
23
24
  needsAgentsMd,
24
25
  } from "./lib/session-context";
25
26
  import { isPalSpawnedInference } from "./lib/spawn-guard";
27
+ import { readStdinJSON } from "./lib/stdin";
26
28
 
27
29
  // Recursion guard — when this process is a PAL-spawned inference subprocess,
28
30
  // skip all context loading so we don't trigger another inference call.
@@ -47,11 +49,21 @@ try {
47
49
 
48
50
  try {
49
51
  const active = getActiveAgent();
52
+ const input = await readStdinJSON<{ source?: string }>();
53
+ const wanted = contextForSource(active, input?.source);
54
+ if (wanted === "none") {
55
+ logDebug("LoadContext", `source=${input?.source}, context already present`);
56
+ process.exit(0);
57
+ }
58
+
50
59
  // The reminder is built for one of three targets; every other agent reads the
51
60
  // same shape Claude Code does.
52
61
  const target: AgentTarget =
53
62
  active === "copilot" || active === "cursor" ? active : "claude";
54
- const reminder = buildSystemReminder({ agent: target });
63
+ const reminder = buildSystemReminder({
64
+ agent: target,
65
+ withoutHandoff: wanted === "without-handoff",
66
+ });
55
67
  if (!reminder) process.exit(0);
56
68
  logContextSnapshot(reminder);
57
69
 
@@ -17,6 +17,7 @@ import { resolve } from "node:path";
17
17
  import { persistLastExchange } from "./handlers/persist-last-exchange";
18
18
  import { logDebug, logError } from "./lib/log";
19
19
  import { paths } from "./lib/paths";
20
+ import { sessionDir } from "./lib/session-dir";
20
21
  import { isPalSpawnedInference } from "./lib/spawn-guard";
21
22
  import { readStdinJSON } from "./lib/stdin";
22
23
  import { readTranscriptFile } from "./lib/transcript";
@@ -27,7 +28,6 @@ if (isPalSpawnedInference()) process.exit(0);
27
28
  interface PreCompactInput {
28
29
  session_id?: string;
29
30
  transcript_path?: string;
30
- cwd?: string;
31
31
  hook_event_name?: string;
32
32
  trigger?: "manual" | "auto";
33
33
  custom_instructions?: string;
@@ -47,7 +47,7 @@ const main = async () => {
47
47
  process.exit(0);
48
48
  }
49
49
  const sessionId = input.session_id ?? "unknown";
50
- const cwd = input.cwd ?? process.cwd();
50
+ const cwd = sessionDir();
51
51
 
52
52
  // Stop fires after every response and is authoritative. Skip if it already
53
53
  // wrote latest.json for this session — PreCompact is a safety net only.
@@ -4,22 +4,22 @@
4
4
  * Writes two outputs:
5
5
  * 1. last-exchange/{sessionId}.json + last-exchange/latest.json
6
6
  * → read by CompactRecover to re-inject after compaction
7
- * 2. last-handoff.json keyed by cwd
8
- * → read by loadHandoff() to surface "Pick Up Where You Left Off"
7
+ * 2. last-handoff.json keyed by the session's start folder
8
+ * → read by loadHandoffContext() to surface "Pick Up Where You Left Off"
9
9
  *
10
- * last-exchange is always overwritten — Stop is its source of truth. last-handoff
11
- * is NOT: a deliberate LEARN-phase note (written by handoff-note.ts with
12
- * source:"deliberate") outranks this raw auto-snapshot, so we leave it intact
13
- * while it is still fresh and in-progress. Otherwise the auto-snapshot would
14
- * clobber the curated handoff the moment the session stopped (ISC-39).
10
+ * last-exchange is always overwritten — Stop is its source of truth. In
11
+ * last-handoff only the exchange fields are: a deliberate LEARN-phase note
12
+ * (source:"deliberate") and the summary session intelligence wrote for this
13
+ * session both stay beside it (ISC-39).
15
14
  */
16
15
 
17
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
18
17
  import { resolve } from "node:path";
18
+ import { trimAtWord } from "../lib/handoff-context";
19
19
  import { logDebug, logError } from "../lib/log";
20
20
  import { ensureDir, paths } from "../lib/paths";
21
+ import { sessionDir } from "../lib/session-dir";
21
22
  import { extractContent, extractLastAssistant, extractLastUser } from "../lib/transcript";
22
- import { detectStatus } from "../lib/work-tracking";
23
23
 
24
24
  type ParsedMessage = { role: string; content: unknown };
25
25
 
@@ -39,34 +39,124 @@ interface AutoHandoff {
39
39
  title: string;
40
40
  status: string;
41
41
  handoff: string;
42
+ waitingOn?: string;
43
+ sessionId?: string;
42
44
  }
43
45
 
44
- function readHandoffs(handoffPath: string): Record<string, unknown> {
46
+ interface LastExchange {
47
+ sessionId: string;
48
+ lastUser: string;
49
+ lastAssistant: string;
50
+ }
51
+
52
+ type StoredHandoff = Partial<AutoHandoff & LastExchange> & {
53
+ timestamp?: string;
54
+ source?: string;
55
+ artifacts?: string[];
56
+ };
57
+
58
+ const LAST_USER_MAX_CHARS = 200;
59
+ const LAST_ASSISTANT_MAX_CHARS = 300;
60
+
61
+ function handoffPath(): string {
62
+ return resolve(ensureDir(paths.state()), "last-handoff.json");
63
+ }
64
+
65
+ function readHandoffs(): Record<string, StoredHandoff> {
45
66
  try {
46
- return existsSync(handoffPath) ? JSON.parse(readFileSync(handoffPath, "utf-8")) : {};
67
+ const p = handoffPath();
68
+ return existsSync(p) ? JSON.parse(readFileSync(p, "utf-8")) : {};
47
69
  } catch {
48
70
  return {};
49
71
  }
50
72
  }
51
73
 
52
- /** Record this cwd's handoff unless a fresh deliberate note already owns it (ISC-39). */
74
+ function writeHandoffs(handoffs: Record<string, StoredHandoff>): void {
75
+ writeFileSync(handoffPath(), JSON.stringify(handoffs, null, 2), "utf-8");
76
+ }
77
+
78
+ export function structuredHandoff(parts: {
79
+ done: string;
80
+ next: string;
81
+ waitingOn: string;
82
+ }): string {
83
+ return [
84
+ parts.done && `Done: ${parts.done}`,
85
+ parts.next && `Next: ${parts.next}`,
86
+ parts.waitingOn && `Waiting on you: ${parts.waitingOn}`,
87
+ ]
88
+ .filter(Boolean)
89
+ .join("\n");
90
+ }
91
+
92
+ function sameSession(entry: StoredHandoff | undefined, sessionId?: string): boolean {
93
+ return sessionId !== undefined && entry?.sessionId === sessionId;
94
+ }
95
+
96
+ /** Record this folder's summary unless a fresh deliberate note already owns it (ISC-39). */
53
97
  export function writeAutoHandoff(cwd: string, entry: AutoHandoff): void {
54
- const handoffPath = resolve(ensureDir(paths.state()), "last-handoff.json");
55
- const existing = readHandoffs(handoffPath);
56
- if (isProtectedHandoff(existing[cwd])) return;
57
- existing[cwd] = {
98
+ const handoffs = readHandoffs();
99
+ const existing = handoffs[cwd];
100
+ if (isProtectedHandoff(existing)) return;
101
+ const exchange = sameSession(existing, entry.sessionId)
102
+ ? { lastUser: existing?.lastUser, lastAssistant: existing?.lastAssistant }
103
+ : {};
104
+ handoffs[cwd] = {
58
105
  timestamp: new Date().toISOString(),
59
106
  ...entry,
107
+ ...exchange,
60
108
  artifacts: [],
61
109
  source: "auto",
62
110
  };
63
- writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
111
+ writeHandoffs(handoffs);
112
+ }
113
+
114
+ function closingParagraph(text: string): string {
115
+ const paragraphs = text
116
+ .split(/\n\s*\n/)
117
+ .map((p) => p.trim())
118
+ .filter((p) => p.length >= 15);
119
+ return paragraphs.at(-1) ?? text;
120
+ }
121
+
122
+ /**
123
+ * The raw exchange is written on every stop; the summary only every few
124
+ * messages. So within one session the exchange is refreshed beside the
125
+ * summary rather than over it, and a new session starts a clean entry.
126
+ */
127
+ function recordLastExchange(cwd: string, exchange: LastExchange): void {
128
+ const handoffs = readHandoffs();
129
+ const existing = handoffs[cwd];
130
+ const trimmed = {
131
+ sessionId: exchange.sessionId,
132
+ lastUser: trimAtWord(exchange.lastUser, LAST_USER_MAX_CHARS),
133
+ lastAssistant: trimAtWord(
134
+ closingParagraph(exchange.lastAssistant),
135
+ LAST_ASSISTANT_MAX_CHARS
136
+ ),
137
+ };
138
+ if (isProtectedHandoff(existing)) {
139
+ handoffs[cwd] = { ...existing, ...trimmed };
140
+ } else if (sameSession(existing, exchange.sessionId)) {
141
+ handoffs[cwd] = { ...existing, ...trimmed, timestamp: new Date().toISOString() };
142
+ } else {
143
+ handoffs[cwd] = {
144
+ timestamp: new Date().toISOString(),
145
+ title: trimAtWord(exchange.lastUser, 80) || "Session",
146
+ status: "in-progress",
147
+ handoff: "",
148
+ artifacts: [],
149
+ source: "auto",
150
+ ...trimmed,
151
+ };
152
+ }
153
+ writeHandoffs(handoffs);
64
154
  }
65
155
 
66
156
  export function persistLastExchange(
67
157
  messages: ParsedMessage[],
68
158
  sessionId: string,
69
- cwd: string = process.cwd()
159
+ cwd: string = sessionDir()
70
160
  ): void {
71
161
  try {
72
162
  const lastUser = extractContent(extractLastUser(messages));
@@ -87,17 +177,7 @@ export function persistLastExchange(
87
177
  writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
88
178
  writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
89
179
 
90
- const handoff = [
91
- lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
92
- lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
93
- ]
94
- .filter(Boolean)
95
- .join("");
96
- writeAutoHandoff(cwd, {
97
- title: (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim(),
98
- status: detectStatus(lastAssistant),
99
- handoff,
100
- });
180
+ recordLastExchange(cwd, { sessionId, lastUser, lastAssistant });
101
181
 
102
182
  logDebug(
103
183
  "persist-last-exchange",
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Stop handler: unified session intelligence capture.
3
3
  *
4
- * Produces: title, summary, insights, handoff via Haiku.
5
- * Writes: session learning file, project history, auto handoff (in-progress only).
4
+ * Produces: title, summary, insights, status and a done/next/waiting handoff via Haiku.
5
+ * Writes: session learning file, project history, auto handoff.
6
6
  *
7
7
  * Relationship notes → written in ALGORITHM LEARN phase via relationship-note.ts
8
8
  * Handoff notes → written in ALGORITHM LEARN phase via handoff-note.ts
@@ -23,6 +23,7 @@ import { canInfer, inference } from "../lib/inference";
23
23
  import { categorizeLearning } from "../lib/learning-category";
24
24
  import { logDebug, logError } from "../lib/log";
25
25
  import { ensureDir, paths } from "../lib/paths";
26
+ import { sessionDir } from "../lib/session-dir";
26
27
  import { fileTimestamp, monthPath } from "../lib/time";
27
28
  import { logTokenUsage } from "../lib/token-usage";
28
29
  import {
@@ -31,8 +32,8 @@ import {
31
32
  extractLastUser,
32
33
  parseMessages,
33
34
  } from "../lib/transcript";
34
- import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
35
- import { writeAutoHandoff } from "./persist-last-exchange";
35
+ import { appendProjectHistory } from "../lib/work-tracking";
36
+ import { structuredHandoff, writeAutoHandoff } from "./persist-last-exchange";
36
37
 
37
38
  // ── JSON schema for merged Haiku call ──
38
39
 
@@ -51,20 +52,51 @@ const INTELLIGENCE_SCHEMA = {
51
52
  description:
52
53
  "What worked, what was surprising, what to do differently, 2-3 bullet points",
53
54
  },
54
- handoff: {
55
+ status: {
55
56
  type: "string" as const,
57
+ enum: ["in-progress", "completed"],
56
58
  description:
57
- "If status is in-progress: what remains to be done, key decisions made, blockers. If completed: empty string.",
59
+ "in-progress if work remains or the AI's last message awaits the user's answer; completed only if nothing is left",
60
+ },
61
+ done: {
62
+ type: "string" as const,
63
+ description: "What the latest piece of work finished, one sentence",
64
+ },
65
+ next: {
66
+ type: "string" as const,
67
+ description: "The concrete next step, one sentence, empty if none",
68
+ },
69
+ waitingOn: {
70
+ type: "string" as const,
71
+ description:
72
+ "What the AI needs from the user before it can continue, empty if nothing",
58
73
  },
59
74
  },
60
- required: ["title", "summary", "insights", "handoff"] as const,
75
+ required: [
76
+ "title",
77
+ "summary",
78
+ "insights",
79
+ "status",
80
+ "done",
81
+ "next",
82
+ "waitingOn",
83
+ ] as const,
61
84
  };
62
85
 
63
86
  interface IntelligenceOutput {
64
87
  title: string;
65
88
  summary: string;
66
89
  insights: string;
67
- handoff: string;
90
+ status: "in-progress" | "completed";
91
+ done: string;
92
+ next: string;
93
+ waitingOn: string;
94
+ }
95
+
96
+ /** A reply states its verdict first and its question to the user last. */
97
+ export function openingAndClosing(text: string, each: number): string {
98
+ if (text.length <= each * 2) return text;
99
+ return `${text.slice(0, each)} … ${text.slice(-each)}`;
68
100
  }
69
101
 
70
102
  // ── Main handler ──
@@ -96,10 +128,9 @@ export async function captureSessionIntelligence(
96
128
  const lastAssistant = extractLastAssistant(messages);
97
129
  const lastAssistantText = extractContent(lastAssistant);
98
130
  const lastUser = extractLastUser(messages);
99
- const status = detectStatus(lastAssistantText);
100
131
 
101
132
  const userWindow = userMessages.slice(-10).map((t) => t.slice(0, 200));
102
- const assistantWindow = lastAssistantText.slice(0, 600);
133
+ const assistantWindow = openingAndClosing(lastAssistantText, 300);
103
134
 
104
135
  if (userWindow.length < 3) return;
105
136
 
@@ -111,17 +142,15 @@ export async function captureSessionIntelligence(
111
142
  const result = await inference({
112
143
  system: [
113
144
  "You analyze a session between a human user and an AI assistant. Sessions may involve coding, research, writing, planning, analysis, or any other task.",
114
- `Session status: ${status}.`,
115
145
  "Produce ALL of the following:",
116
146
  "1. title: short title (5-10 words) describing what was accomplished",
117
147
  "2. summary: what the AI did for the user (2-4 sentences, AI perspective using 'we')",
118
148
  "3. insights: what worked, what was surprising, what to do differently (2-3 points, no markdown)",
119
- status === "in-progress"
120
- ? "4. handoff: what remains unfinished — decisions made so far, next steps, blockers (2-4 sentences)"
121
- : "4. handoff: empty string (session completed)",
149
+ "4. status: in-progress if work remains or the last AI response ends with a question or offer awaiting the user; completed only if nothing is left",
150
+ "5. done, next, waitingOn: a handoff for whoever picks this up next. Describe the LATEST thread of work, the one the last messages are about, not earlier topics of the session. One sentence each; next and waitingOn may be empty.",
122
151
  ].join("\n"),
123
152
  user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
124
- maxTokens: 350,
153
+ maxTokens: 450,
125
154
  timeout: 90000,
126
155
  jsonSchema: INTELLIGENCE_SCHEMA,
127
156
  caller: "session-intelligence",
@@ -141,8 +170,14 @@ export async function captureSessionIntelligence(
141
170
  const title = output?.title || extractContent(lastUser).slice(0, 80) || "session";
142
171
  const summary = output?.summary || lastAssistantText.slice(0, 600);
143
172
  const insights = output?.insights || "";
144
- if (status === "in-progress" && output?.handoff) {
145
- writeAutoHandoff(process.cwd(), { title, status, handoff: output.handoff });
173
+ if (output) {
174
+ writeAutoHandoff(sessionDir(), {
175
+ title,
176
+ status: output.status,
177
+ handoff: structuredHandoff(output),
178
+ waitingOn: output.waitingOn,
179
+ sessionId,
180
+ });
146
181
  }
147
182
  // ── Write session learning file ──
148
183
 
@@ -9,15 +9,16 @@ 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 { readLearnings } from "./learning-store";
12
+ import { loadHandoffContext } from "./handoff-context";
13
13
  import { loadOpinionContext } from "./opinions";
14
14
  import { paths, toPath } from "./paths";
15
15
  import { loadActiveProjectsContext } from "./projects";
16
16
  import { loadRecentNotes } from "./relationship";
17
17
  import { loadFailurePatterns } from "./semi-static";
18
+ import { sessionDir } from "./session-dir";
18
19
  import * as settings from "./settings";
19
20
  import { readFramePrinciples } from "./wisdom";
20
- import { readProjectHistory } from "./work-tracking";
21
+ import { lastSessionTitle, readProjectHistory } from "./work-tracking";
21
22
 
22
23
  /** Load and concatenate loadAtStartup files */
23
24
  function loadStartupFiles(): string {
@@ -51,29 +52,6 @@ export function loadWisdomContext(): string {
51
52
  }
52
53
  }
53
54
 
54
- /** Load recent session learning files as digest, with detail for current project */
55
- export function loadLearningDigest(): string {
56
- try {
57
- const cwd = process.cwd();
58
- const entries = readLearnings(paths.sessionLearning(), 10);
59
- if (entries.length === 0) return "";
60
-
61
- // This-project learnings are now in loadProjectHistoryContext(); only show cross-project here
62
- const other = entries.filter((e) => e.cwd !== cwd).slice(0, 5);
63
-
64
- if (other.length === 0) return "";
65
-
66
- const lines: string[] = [];
67
-
68
- lines.push("## Other Recent Learnings");
69
- for (const e of other) lines.push(`- ${e.title}`);
70
-
71
- return lines.join("\n");
72
- } catch {
73
- return "";
74
- }
75
- }
76
-
77
55
  /** Load self-model for session context injection */
78
56
  function loadSelfModel(): string {
79
57
  try {
@@ -219,31 +197,6 @@ function loadSessionIntelligence(): string {
219
197
  }
220
198
  }
221
199
 
222
- /** Load handoff state for the current project */
223
- function loadHandoff(): string {
224
- try {
225
- const p = resolve(paths.state(), "last-handoff.json");
226
- if (!existsSync(p)) return "";
227
- const handoffs = JSON.parse(readFileSync(p, "utf-8"));
228
- const cwd = process.cwd();
229
- const entry = handoffs[cwd];
230
- if (!entry?.handoff || entry.status !== "in-progress") return "";
231
-
232
- const age = Date.now() - new Date(entry.timestamp).getTime();
233
- if (age > 7 * 24 * 60 * 60 * 1000) return ""; // stale after 7 days
234
-
235
- return [
236
- "## Pick Up Where You Left Off",
237
- `*Previous session: ${entry.title}*`,
238
- "",
239
- entry.handoff,
240
- "→ Continue this work or explicitly close it before starting something new.",
241
- ].join("\n");
242
- } catch {
243
- return "";
244
- }
245
- }
246
-
247
200
  /** Truncate text to maxChars at the last complete line boundary */
248
201
  function capSection(text: string, maxChars: number): string {
249
202
  if (text.length <= maxChars) return text;
@@ -272,7 +225,9 @@ export type AgentTarget = "claude" | "opencode" | "cursor" | "copilot";
272
225
  * opts.agent — agent target; Claude Code skips semi-static sections (self-model,
273
226
  * wisdom, opinions) that load natively via @imports in CLAUDE.md.
274
227
  */
275
- export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string {
228
+ export function buildSystemReminder(
229
+ opts: { agent?: AgentTarget; withoutHandoff?: boolean } = {}
230
+ ): string {
276
231
  // Semi-static sections loaded natively via @imports (Claude Code) or
277
232
  // instructions[] (opencode). Skip them from hook output for those agents.
278
233
  const skipSemiStatic =
@@ -287,12 +242,11 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
287
242
  const relationship = settings.isEnabled("relationship")
288
243
  ? loadRelationshipContext()
289
244
  : "";
290
- const digest = settings.isEnabled("learningDigest") ? loadLearningDigest() : "";
291
245
  const projectHistory = settings.isEnabled("projectHistory")
292
246
  ? loadProjectHistoryContext()
293
247
  : "";
294
248
  const activeProjects = settings.isEnabled("projects")
295
- ? loadActiveProjectsContext()
249
+ ? loadActiveProjectsContext(process.cwd(), lastSessionTitle)
296
250
  : "";
297
251
  const failures =
298
252
  settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
@@ -303,7 +257,10 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
303
257
  const intelligence = settings.isEnabled("sessionIntelligence")
304
258
  ? loadSessionIntelligence()
305
259
  : "";
306
- const handoff = settings.isEnabled("handoff") ? loadHandoff() : "";
260
+ const handoff =
261
+ !opts.withoutHandoff && settings.isEnabled("handoff")
262
+ ? loadHandoffContext(sessionDir())
263
+ : "";
307
264
  // Maintainer-only: self-gates to a repo checkout, "" for everyone else.
308
265
  const algoReview = loadAlgorithmReviewNudge();
309
266
  const reflectNudge = loadReflectNudge();
@@ -321,7 +278,6 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
321
278
  if (relationship) parts.push(relationship);
322
279
  if (activeProjects) parts.push(activeProjects);
323
280
  if (projectHistory) parts.push(projectHistory);
324
- if (digest) parts.push(digest);
325
281
  if (failures) parts.push(failures);
326
282
  if (parts.length === 0) return "";
327
283
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { resolve } from "node:path";
15
- import { hasApiKey } from "./inference";
15
+ import { canInfer } from "./inference";
16
16
  import {
17
17
  type FailureEntry,
18
18
  type LearningEntry,
@@ -215,7 +215,7 @@ async function generateRecommendations(
215
215
  ratings: RatingsSummary | null
216
216
  ): Promise<string[]> {
217
217
  if (candidates.length === 0 && !ratings) return [];
218
- if (!hasApiKey()) {
218
+ if (!canInfer()) {
219
219
  return candidates
220
220
  .slice(0, 3)
221
221
  .map(
@@ -0,0 +1,112 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { resolve } from "node:path";
4
+ import { paths } from "./paths";
5
+
6
+ interface HandoffEntry {
7
+ title?: string;
8
+ handoff?: string;
9
+ status?: string;
10
+ timestamp?: string;
11
+ lastUser?: string;
12
+ lastAssistant?: string;
13
+ }
14
+
15
+ const HANDOFF_STALE_MS = 7 * 24 * 60 * 60 * 1000;
16
+ const ELSEWHERE_MAX_CHARS = 300;
17
+
18
+ function readHandoffs(): Record<string, HandoffEntry> {
19
+ const p = resolve(paths.state(), "last-handoff.json");
20
+ if (!existsSync(p)) return {};
21
+ try {
22
+ return JSON.parse(readFileSync(p, "utf-8"));
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ function isFresh(entry: HandoffEntry, now: number): boolean {
29
+ if (!(entry.handoff || entry.lastUser) || !entry.timestamp) return false;
30
+ return now - new Date(entry.timestamp).getTime() <= HANDOFF_STALE_MS;
31
+ }
32
+
33
+ function isScratchDir(dir: string): boolean {
34
+ return dir.startsWith(tmpdir());
35
+ }
36
+
37
+ export function trimAtWord(text: string, max: number): string {
38
+ const flat = text.replace(/\s+/g, " ").trim();
39
+ if (flat.length <= max) return flat;
40
+ const cut = flat.slice(0, max);
41
+ const lastSpace = cut.lastIndexOf(" ");
42
+ return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
43
+ }
44
+
45
+ function formatAgo(timestamp: string, now: number): string {
46
+ const minutes = Math.floor((now - new Date(timestamp).getTime()) / 60_000);
47
+ if (minutes < 60) return `${minutes}m ago`;
48
+ const hours = Math.floor(minutes / 60);
49
+ if (hours < 24) return `${hours}h ago`;
50
+ return `${Math.floor(hours / 24)}d ago`;
51
+ }
52
+
53
+ function lastExchangeLines(entry: HandoffEntry): string[] {
54
+ if (!entry.lastUser) return [];
55
+ const lines = ["Last exchange:", `- User: ${entry.lastUser}`];
56
+ if (entry.lastAssistant) lines.push(`- You: ${entry.lastAssistant}`);
57
+ return lines;
58
+ }
59
+
60
+ /** A finished session is still shown, for follow-ups, just not as open work. */
61
+ function hereSection(entry: HandoffEntry | undefined, now: number): string {
62
+ if (!entry || !isFresh(entry, now)) return "";
63
+ const open = entry.status !== "completed";
64
+ return [
65
+ "## Pick Up Where You Left Off",
66
+ `*Previous session: ${entry.title} · ${formatAgo(entry.timestamp ?? "", now)}*`,
67
+ ...(entry.handoff ? [entry.handoff] : []),
68
+ ...lastExchangeLines(entry),
69
+ ...(open
70
+ ? ["→ Continue this work or explicitly close it before starting something new."]
71
+ : []),
72
+ ].join("\n");
73
+ }
74
+
75
+ function latestElsewhere(
76
+ handoffs: Record<string, HandoffEntry>,
77
+ here: string,
78
+ now: number
79
+ ): [string, HandoffEntry] | undefined {
80
+ return Object.entries(handoffs)
81
+ .filter(([dir, entry]) => dir !== here && !isScratchDir(dir) && isFresh(entry, now))
82
+ .sort(([, a], [, b]) => (b.timestamp ?? "").localeCompare(a.timestamp ?? ""))[0];
83
+ }
84
+
85
+ function elsewhereSection(
86
+ handoffs: Record<string, HandoffEntry>,
87
+ here: string,
88
+ now: number
89
+ ): string {
90
+ const latest = latestElsewhere(handoffs, here, now);
91
+ if (!latest) return "";
92
+ const [dir, entry] = latest;
93
+ const hereEntry = handoffs[here];
94
+ if (hereEntry?.timestamp && hereEntry.timestamp > (entry.timestamp ?? "")) return "";
95
+ return [
96
+ "## Last Conversation Elsewhere",
97
+ `*${dir} · ${formatAgo(entry.timestamp ?? "", now)} · may be unrelated: ${entry.title}*`,
98
+ trimAtWord(entry.handoff || `User: ${entry.lastUser}`, ELSEWHERE_MAX_CHARS),
99
+ ].join("\n");
100
+ }
101
+
102
+ /**
103
+ * This folder's handoff comes first and in full, since it is what a session
104
+ * started here usually continues. The latest conversation from any other
105
+ * folder follows in brief, only when it is newer than this folder's.
106
+ */
107
+ export function loadHandoffContext(here: string, now: number = Date.now()): string {
108
+ const handoffs = readHandoffs();
109
+ return [hereSection(handoffs[here], now), elsewhereSection(handoffs, here, now)]
110
+ .filter(Boolean)
111
+ .join("\n\n");
112
+ }
@@ -391,10 +391,14 @@ const MAX_INLINE_BULLETS = 3;
391
391
  * Format the SessionStart "Active Projects" section.
392
392
  *
393
393
  * For the cwd-resolved project (`→ here`): full detail with Context, Objectives
394
- * (from goal section), Next, and Blockers. For all others: compact one-liner with
395
- * next/blocker counts only. Stale flag (>14d) and browse-mode hint included.
394
+ * (from goal section), Next, and Blockers. Other recent projects: one line with
395
+ * counts and the last session's title, enough to follow up from any folder.
396
+ * Quiet ones (>14d) collapse to a single line of names.
396
397
  */
397
- export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
398
+ export function loadActiveProjectsContext(
399
+ cwd: string = process.cwd(),
400
+ lastSessionTitle: (project: string) => string | undefined = () => undefined
401
+ ): string {
398
402
  const all = readAllProjects();
399
403
  const visible = all.filter((p) => p.status === "active" || p.status === "paused");
400
404
  const resolved = resolveProjectFromCwd(cwd, visible);
@@ -411,6 +415,7 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
411
415
  if (visible.length > 0) {
412
416
  lines.push("## Active Projects", "");
413
417
  const sorted = [...visible].sort((a, b) => b.updated.localeCompare(a.updated));
418
+ const quiet: string[] = [];
414
419
  for (const p of sorted) {
415
420
  const ago = formatAgo(p.updated);
416
421
  const stale = isStale(p) ? " ⚠ stale" : "";
@@ -447,14 +452,21 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
447
452
  if (p.blockers?.length) {
448
453
  lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
449
454
  }
455
+ } else if (stale) {
456
+ quiet.push(p.name);
450
457
  } else {
451
458
  const counts: string[] = [];
452
459
  if (p.next?.length) counts.push(`${p.next.length} next`);
453
460
  if (p.blockers?.length) counts.push(`${p.blockers.length} blockers`);
454
461
  const countsSuffix = counts.length > 0 ? ` — ${counts.join(", ")}` : "";
455
- lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${stale}`);
462
+ const last = lastSessionTitle(p.name);
463
+ const lastSuffix = last ? ` — last: ${last}` : "";
464
+ lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${lastSuffix}`);
456
465
  }
457
466
  }
467
+ if (quiet.length > 0) {
468
+ lines.push(`- Quiet for ${PROJECT_STALE_DAYS_DEFAULT}+ days: ${quiet.join(", ")}`);
469
+ }
458
470
  }
459
471
 
460
472
  if (showHint) {
@@ -24,6 +24,28 @@ export function isSubagentSession(env: NodeJS.ProcessEnv): boolean {
24
24
  return env.CLAUDE_PROJECT_DIR?.includes("/.claude/Agents/") ?? false;
25
25
  }
26
26
 
27
+ export type SessionStartContext = "full" | "without-handoff" | "none";
28
+
29
+ /**
30
+ * Claude Code and Codex replay the context injected at startup when a session
31
+ * is resumed. Copilot does not document it, and has shipped a release that
32
+ * dropped startup context, so it keeps getting the full context on resume.
33
+ */
34
+ const RESUME_KEEPS_CONTEXT: ReadonlySet<string> = new Set(["claude", "codex"]);
35
+
36
+ /**
37
+ * After a compaction, CompactRecover restores the last exchange, so the
38
+ * handoff is redundant. Agents that name no source get everything.
39
+ */
40
+ export function contextForSource(
41
+ agent: string,
42
+ source: string | undefined
43
+ ): SessionStartContext {
44
+ if (source === "resume" && RESUME_KEEPS_CONTEXT.has(agent)) return "none";
45
+ if (source === "compact") return "without-handoff";
46
+ return "full";
47
+ }
48
+
27
49
  /**
28
50
  * Copilot and Cursor read AGENTS.md natively from nothing, so it is prepended
29
51
  * here; Codex reaches it through a symlink and Claude Code loads it itself.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The folder a session was started in. A hook's own cwd follows every `cd`
3
+ * the agent makes, so keying by it files a session under whatever subfolder
4
+ * it happened to end in. Claude Code exports the start folder as
5
+ * CLAUDE_PROJECT_DIR; agents that do not fall back to the cwd.
6
+ */
7
+ export function sessionDir(env: NodeJS.ProcessEnv = process.env): string {
8
+ return env.CLAUDE_PROJECT_DIR || process.cwd();
9
+ }
@@ -169,7 +169,15 @@ export function appendProjectHistory(cwd: string, entry: ProjectHistoryEntry): v
169
169
 
170
170
  /** Read the session history for a given cwd */
171
171
  export function readProjectHistory(cwd: string, limit = 15): ProjectHistoryEntry[] {
172
- const historyPath = historyFileFor(cwd);
172
+ return readHistoryFile(historyFileFor(cwd), limit);
173
+ }
174
+
175
+ export function lastSessionTitle(project: string): string | undefined {
176
+ const historyPath = resolve(paths.projectHistory(), project, "history.jsonl");
177
+ return readHistoryFile(historyPath, 1)[0]?.title;
178
+ }
179
+
180
+ function readHistoryFile(historyPath: string, limit: number): ProjectHistoryEntry[] {
173
181
  if (!existsSync(historyPath)) return [];
174
182
  try {
175
183
  const lines = readFileSync(historyPath, "utf-8").trim().split("\n").filter(Boolean);