portable-agent-layer 0.78.3 → 0.79.1
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 -1
- package/assets/templates/pal-settings.json +0 -1
- package/package.json +1 -1
- package/src/hooks/LoadContext.ts +13 -1
- package/src/hooks/PreCompactPersist.ts +2 -2
- package/src/hooks/handlers/persist-last-exchange.ts +108 -28
- package/src/hooks/handlers/session-intelligence.ts +52 -17
- package/src/hooks/lib/context.ts +11 -55
- package/src/hooks/lib/graduation.ts +2 -2
- package/src/hooks/lib/handoff-context.ts +112 -0
- package/src/hooks/lib/projects.ts +16 -4
- package/src/hooks/lib/session-context.ts +22 -0
- package/src/hooks/lib/session-dir.ts +9 -0
- package/src/hooks/lib/work-tracking.ts +9 -1
- package/src/targets/lib.ts +13 -47
|
@@ -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." },
|
package/package.json
CHANGED
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -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({
|
|
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 =
|
|
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
|
|
8
|
-
* → read by
|
|
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.
|
|
11
|
-
*
|
|
12
|
-
* source:"deliberate")
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
const existing =
|
|
56
|
-
if (isProtectedHandoff(existing
|
|
57
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
+
status: {
|
|
55
56
|
type: "string" as const,
|
|
57
|
+
enum: ["in-progress", "completed"],
|
|
56
58
|
description:
|
|
57
|
-
"
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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
|
|
120
|
-
|
|
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:
|
|
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 (
|
|
145
|
-
writeAutoHandoff(
|
|
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
|
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 =
|
|
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 {
|
|
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 (!
|
|
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.
|
|
395
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/targets/lib.ts
CHANGED
|
@@ -155,6 +155,15 @@ export function loadSettingsTemplate(templatePath: string, pkgRoot: string): Set
|
|
|
155
155
|
* - permissions.allow: deduplicate by value
|
|
156
156
|
* - other keys: template values are added if not already present
|
|
157
157
|
*/
|
|
158
|
+
/** Set by the template on first install; after that the user's value wins. */
|
|
159
|
+
const USER_OWNED_SETTINGS = [
|
|
160
|
+
"attribution",
|
|
161
|
+
"showClearContextOnPlanAccept",
|
|
162
|
+
"respectGitignore",
|
|
163
|
+
"spinnerTipsEnabled",
|
|
164
|
+
"autoMemoryEnabled",
|
|
165
|
+
] as const;
|
|
166
|
+
|
|
158
167
|
export function mergeSettings(existing: Settings, template: Settings): Settings {
|
|
159
168
|
const result = { ...existing };
|
|
160
169
|
|
|
@@ -220,32 +229,8 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
|
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (!("attribution" in result)) {
|
|
226
|
-
result.attribution = template.attribution;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Merge showClearContextOnPlanAccept (boolean, add if not present)
|
|
231
|
-
if ("showClearContextOnPlanAccept" in template) {
|
|
232
|
-
if (!("showClearContextOnPlanAccept" in result)) {
|
|
233
|
-
result.showClearContextOnPlanAccept = template.showClearContextOnPlanAccept;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Merge respectGitignore (boolean, add if not present)
|
|
238
|
-
if ("respectGitignore" in template) {
|
|
239
|
-
if (!("respectGitignore" in result)) {
|
|
240
|
-
result.respectGitignore = template.respectGitignore;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Merge spinnerTipsEnabled (boolean, add if not present)
|
|
245
|
-
if ("spinnerTipsEnabled" in template) {
|
|
246
|
-
if (!("spinnerTipsEnabled" in result)) {
|
|
247
|
-
result.spinnerTipsEnabled = template.spinnerTipsEnabled;
|
|
248
|
-
}
|
|
232
|
+
for (const key of USER_OWNED_SETTINGS) {
|
|
233
|
+
if (key in template && !(key in result)) result[key] = template[key];
|
|
249
234
|
}
|
|
250
235
|
|
|
251
236
|
// Merge spinnerTipsOverride (merge tips array, deduplicate by content)
|
|
@@ -322,27 +307,8 @@ export function unmergeSettings(existing: Settings, template: Settings): Setting
|
|
|
322
307
|
if (Object.keys(result.skillOverrides).length === 0) delete result.skillOverrides;
|
|
323
308
|
}
|
|
324
309
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
delete result.attribution;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Remove PAL showClearContextOnPlanAccept
|
|
331
|
-
if (
|
|
332
|
-
"showClearContextOnPlanAccept" in template &&
|
|
333
|
-
"showClearContextOnPlanAccept" in result
|
|
334
|
-
) {
|
|
335
|
-
delete result.showClearContextOnPlanAccept;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Remove PAL respectGitignore
|
|
339
|
-
if ("respectGitignore" in template && "respectGitignore" in result) {
|
|
340
|
-
delete result.respectGitignore;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Remove PAL spinnerTipsEnabled
|
|
344
|
-
if ("spinnerTipsEnabled" in template && "spinnerTipsEnabled" in result) {
|
|
345
|
-
delete result.spinnerTipsEnabled;
|
|
310
|
+
for (const key of USER_OWNED_SETTINGS) {
|
|
311
|
+
if (key in template) delete result[key];
|
|
346
312
|
}
|
|
347
313
|
|
|
348
314
|
// Remove PAL spinnerTipsOverride (remove only template tips, preserve user tips)
|