portable-agent-layer 0.79.2 → 0.80.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.
- package/assets/schema/pal-settings.schema.json +4 -1
- package/assets/templates/PAL/MEMORY_SYSTEM.md +3 -1
- package/assets/templates/pal-settings.json +1 -1
- package/package.json +1 -1
- package/src/hooks/UserPromptOrchestrator.ts +1 -1
- package/src/hooks/handlers/inject-retrieval.ts +11 -3
- package/src/hooks/handlers/persist-last-exchange.ts +13 -1
- package/src/hooks/handlers/rating.ts +1 -29
- package/src/hooks/handlers/session-intelligence.ts +1 -0
- package/src/hooks/lib/context.ts +5 -6
- package/src/hooks/lib/interaction-mood.ts +103 -0
- package/src/hooks/lib/interaction.ts +235 -0
- package/src/hooks/lib/prompt-text.ts +24 -0
- package/src/hooks/lib/relationship.ts +2 -2
- package/src/hooks/lib/stop.ts +24 -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
- package/src/targets/opencode/plugin.ts +1 -1
|
@@ -59,7 +59,6 @@
|
|
|
59
59
|
"relationship": { "type": "boolean", "description": "Recent relationship notes." },
|
|
60
60
|
"failurePatterns": { "type": "boolean", "description": "Recurring failure patterns." },
|
|
61
61
|
"projects": { "type": "boolean", "description": "Active projects list." },
|
|
62
|
-
"sessionIntelligence": { "type": "boolean", "description": "Algorithm reflection stats and this project's self-observations." },
|
|
63
62
|
"handoff": { "type": "boolean", "description": "Handoff note from the previous session." },
|
|
64
63
|
"selfModel": { "type": "boolean", "description": "The assistant's self-model synthesis." },
|
|
65
64
|
"contextualSteering": { "type": "boolean", "description": "Prompt-time steering self-checks (see the steering section)." },
|
|
@@ -71,6 +70,10 @@
|
|
|
71
70
|
"type": "boolean",
|
|
72
71
|
"description": "One line of the current time with every prompt, in identity.principal.timezone, so a session resumed hours later knows the day changed."
|
|
73
72
|
},
|
|
73
|
+
"interactionAwareness": {
|
|
74
|
+
"type": "boolean",
|
|
75
|
+
"description": "Measure each user turn (pace, message length, skimming, corrections; never the text) into memory/signals/interaction/, and tell the agent when the session's pattern changes."
|
|
76
|
+
},
|
|
74
77
|
"steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
|
|
75
78
|
}
|
|
76
79
|
},
|
|
@@ -19,7 +19,8 @@ memory/
|
|
|
19
19
|
│ └── token-usage.jsonl # Per-call inference token usage
|
|
20
20
|
│
|
|
21
21
|
├── signals/
|
|
22
|
-
│
|
|
22
|
+
│ ├── ratings.jsonl # Append-only rating signals (explicit + implicit)
|
|
23
|
+
│ └── interaction/YYYY-MM.jsonl # Measured features of each user turn (never the text)
|
|
23
24
|
│
|
|
24
25
|
├── relationship/
|
|
25
26
|
│ ├── YYYY-MM/YYYY-MM-DD.md # Daily interaction notes (W / O / B)
|
|
@@ -54,6 +55,7 @@ Almost everything in `state/`, `signals/`, `relationship/opinions.json`, `learni
|
|
|
54
55
|
| A reusable session insight | Session-intelligence handler (automatic) | `learning/session/YYYY-MM/*.md` |
|
|
55
56
|
| A failure worth learning from | Failure-principle handler (automatic, low ratings) | `learning/failures/YYYY-MM/*.md` |
|
|
56
57
|
| A rating signal | UserPromptOrchestrator rating capture | `signals/ratings.jsonl` |
|
|
58
|
+
| How a user turn went (pace, length, skimming, corrections) | Prompt-time interaction measurement (automatic) | `signals/interaction/YYYY-MM.jsonl` |
|
|
57
59
|
| A project handoff | Project skill / detached handlers | `projects/<slug>/` |
|
|
58
60
|
|
|
59
61
|
## Format conventions
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
"relationship": true,
|
|
22
22
|
"failurePatterns": true,
|
|
23
23
|
"projects": true,
|
|
24
|
-
"sessionIntelligence": true,
|
|
25
24
|
"handoff": true,
|
|
26
25
|
"selfModel": true,
|
|
27
26
|
"contextualSteering": true,
|
|
28
27
|
"skillMatching": true,
|
|
29
28
|
"wallClock": true,
|
|
29
|
+
"interactionAwareness": true,
|
|
30
30
|
"steeringTestReport": false
|
|
31
31
|
},
|
|
32
32
|
"steering": {
|
package/package.json
CHANGED
|
@@ -30,7 +30,7 @@ logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)
|
|
|
30
30
|
if (!input?.prompt) process.exit(0);
|
|
31
31
|
|
|
32
32
|
const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
|
|
33
|
-
const injected = await injectPromptContext(input.prompt);
|
|
33
|
+
const injected = await injectPromptContext(input.prompt, sessionId);
|
|
34
34
|
logPromptSnapshot(input.prompt, injected);
|
|
35
35
|
|
|
36
36
|
const results = await Promise.allSettled([
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { isCodex, isCursor } from "../lib/agent";
|
|
11
|
+
import { observeTurn } from "../lib/interaction";
|
|
11
12
|
import { logDebug, logError } from "../lib/log";
|
|
12
13
|
import { runRetrieval } from "../lib/retrieval";
|
|
13
14
|
import { ensureIndex } from "../lib/retrieval-index";
|
|
@@ -85,9 +86,13 @@ function writeForAgent(reminder: string): void {
|
|
|
85
86
|
* produced anything. The clock leads: it is the only part that is true of the
|
|
86
87
|
* moment rather than of the prompt.
|
|
87
88
|
* @lintignore dynamically imported by opencode plugin */
|
|
88
|
-
export async function getPromptContext(
|
|
89
|
+
export async function getPromptContext(
|
|
90
|
+
prompt: string,
|
|
91
|
+
sessionId?: string
|
|
92
|
+
): Promise<string | null> {
|
|
89
93
|
const parts = [
|
|
90
94
|
getWallClockReminder(),
|
|
95
|
+
withinBudget(() => observeTurn(prompt, sessionId), BUDGET_MS),
|
|
91
96
|
getSteeringReminder(prompt),
|
|
92
97
|
getSkillReminder(prompt),
|
|
93
98
|
await getRetrievalReminder(prompt),
|
|
@@ -98,8 +103,11 @@ export async function getPromptContext(prompt: string): Promise<string | null> {
|
|
|
98
103
|
|
|
99
104
|
/** Gather all prompt-time context and do the one per-agent write. Returns the
|
|
100
105
|
* combined reminder that was injected, or null if there was nothing to inject. */
|
|
101
|
-
export async function injectPromptContext(
|
|
102
|
-
|
|
106
|
+
export async function injectPromptContext(
|
|
107
|
+
prompt: string,
|
|
108
|
+
sessionId?: string
|
|
109
|
+
): Promise<string | null> {
|
|
110
|
+
const combined = await getPromptContext(prompt, sessionId);
|
|
103
111
|
if (combined) writeForAgent(combined);
|
|
104
112
|
return combined;
|
|
105
113
|
}
|
|
@@ -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,7 @@ import { resolve } from "node:path";
|
|
|
13
13
|
import { spawnDetachedInference } from "../lib/detached-inference";
|
|
14
14
|
import { canInfer, inference } from "../lib/inference";
|
|
15
15
|
import { paths } from "../lib/paths";
|
|
16
|
+
import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
|
|
16
17
|
import { emitRating } from "../lib/signals";
|
|
17
18
|
import { now } from "../lib/time";
|
|
18
19
|
import { logTokenUsage } from "../lib/token-usage";
|
|
@@ -128,35 +129,6 @@ function isPraise(prompt: string): boolean {
|
|
|
128
129
|
);
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
// ── System-Injected Tag Stripping ──
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Strip IDE/system-injected XML tags from the prompt to recover the raw user text.
|
|
135
|
-
* Claude Code VSCode extension prepends tags like <ide_opened_file>...</ide_opened_file>
|
|
136
|
-
* and <ide_selection>...</ide_selection> to the prompt field in hooks.
|
|
137
|
-
*/
|
|
138
|
-
const INJECTED_TAG_RE =
|
|
139
|
-
/<(?:ide_opened_file|ide_selection|system-reminder|task-notification)[^>]*>[\s\S]*?<\/(?:ide_opened_file|ide_selection|system-reminder|task-notification)>/gi;
|
|
140
|
-
|
|
141
|
-
function stripInjectedTags(prompt: string): string {
|
|
142
|
-
return prompt.replace(INJECTED_TAG_RE, "").trim();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ── System Text Filters ──
|
|
146
|
-
|
|
147
|
-
const SYSTEM_TEXT_PATTERNS = [
|
|
148
|
-
/^<task-notification>/i,
|
|
149
|
-
/^<system-reminder>/i,
|
|
150
|
-
/^This session is being continued from a previous conversation/i,
|
|
151
|
-
/^Please continue the conversation/i,
|
|
152
|
-
/^Note:.*was read before/i,
|
|
153
|
-
];
|
|
154
|
-
|
|
155
|
-
function isSystemText(prompt: string): boolean {
|
|
156
|
-
const trimmed = prompt.trim();
|
|
157
|
-
return SYSTEM_TEXT_PATTERNS.some((re) => re.test(trimmed));
|
|
158
|
-
}
|
|
159
|
-
|
|
160
132
|
// ── Sentiment Analysis ──
|
|
161
133
|
|
|
162
134
|
const SENTIMENT_SCHEMA = {
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -117,8 +117,11 @@ export function loadRelationshipContext(): string {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
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 {
|
|
122
125
|
try {
|
|
123
126
|
const p = resolve(paths.state(), "synthesis.json");
|
|
124
127
|
if (!existsSync(p)) return "";
|
|
@@ -211,9 +214,6 @@ export function buildSystemReminder(
|
|
|
211
214
|
!skipSemiStatic && settings.isEnabled("opinions") ? loadOpinionContext() : "";
|
|
212
215
|
const selfModel =
|
|
213
216
|
!skipSemiStatic && settings.isEnabled("selfModel") ? loadSelfModel() : "";
|
|
214
|
-
const intelligence = settings.isEnabled("sessionIntelligence")
|
|
215
|
-
? loadSessionIntelligence()
|
|
216
|
-
: "";
|
|
217
217
|
const handoff =
|
|
218
218
|
!opts.withoutHandoff && settings.isEnabled("handoff")
|
|
219
219
|
? loadHandoffContext(sessionDir())
|
|
@@ -231,7 +231,6 @@ export function buildSystemReminder(
|
|
|
231
231
|
if (selfModel) parts.push(selfModel);
|
|
232
232
|
if (wisdom) parts.push(wisdom);
|
|
233
233
|
if (opinions) parts.push(opinions);
|
|
234
|
-
if (intelligence) parts.push(intelligence);
|
|
235
234
|
if (relationship) parts.push(relationship);
|
|
236
235
|
if (activeProjects) parts.push(activeProjects);
|
|
237
236
|
if (failures) parts.push(failures);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the user's recent turns say about how they want to work right now,
|
|
3
|
+
* measured against their own history rather than a fixed idea of a user.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface MoodTurn {
|
|
7
|
+
words: number;
|
|
8
|
+
gapSec: number | null;
|
|
9
|
+
afterBreak: boolean;
|
|
10
|
+
skimmed: boolean;
|
|
11
|
+
interrupted: boolean;
|
|
12
|
+
repeated: boolean;
|
|
13
|
+
corrected: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Baseline {
|
|
17
|
+
words: number;
|
|
18
|
+
gapSec: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_BASELINE: Baseline = { words: 30, gapSec: 120 };
|
|
22
|
+
|
|
23
|
+
export interface Mood {
|
|
24
|
+
key: string;
|
|
25
|
+
facts: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const WINDOW = 6;
|
|
29
|
+
const MIN_TURNS = 3;
|
|
30
|
+
|
|
31
|
+
export function median(values: number[]): number | null {
|
|
32
|
+
if (values.length === 0) return null;
|
|
33
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
34
|
+
const mid = Math.floor(sorted.length / 2);
|
|
35
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function duration(sec: number): string {
|
|
39
|
+
return sec < 90 ? `${Math.round(sec)}s` : `${Math.round(sec / 60)}m`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A break starts a new sitting; turns before it describe a different moment. */
|
|
43
|
+
function currentSitting(turns: MoodTurn[]): MoodTurn[] {
|
|
44
|
+
const lastBreak = turns.findLastIndex((t) => t.afterBreak);
|
|
45
|
+
return turns.slice(Math.max(lastBreak, 0)).slice(-WINDOW);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Time away is not a signal of focus, so a break's own gap never counts toward pace. */
|
|
49
|
+
function paceFact(sitting: MoodTurn[], base: Baseline): [string, string] | null {
|
|
50
|
+
const gaps = sitting
|
|
51
|
+
.filter((t) => t.gapSec !== null && !t.afterBreak)
|
|
52
|
+
.map((t) => t.gapSec as number);
|
|
53
|
+
const pace = gaps.length >= MIN_TURNS ? median(gaps) : null;
|
|
54
|
+
if (pace === null || pace > base.gapSec / 2) return null;
|
|
55
|
+
return [
|
|
56
|
+
"fast",
|
|
57
|
+
`replying fast (median ${duration(pace)} after your reply, usually ${duration(base.gapSec)})`,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lengthFact(sitting: MoodTurn[], base: Baseline): [string, string] | null {
|
|
62
|
+
if (sitting.length < MIN_TURNS) return null;
|
|
63
|
+
const words = median(sitting.map((t) => t.words)) as number;
|
|
64
|
+
const usual = `median ${Math.round(words)} words, usually ${Math.round(base.words)}`;
|
|
65
|
+
if (words <= base.words / 2) return ["short", `writing short messages (${usual})`];
|
|
66
|
+
if (words >= base.words * 2) return ["long", `writing long messages (${usual})`];
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function countOf(turns: MoodTurn[], test: (t: MoodTurn) => boolean): number {
|
|
71
|
+
return turns.filter(test).length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function skimFact(sitting: MoodTurn[]): [string, string] | null {
|
|
75
|
+
if (countOf(sitting.slice(-3), (t) => t.skimmed) < 2) return null;
|
|
76
|
+
return ["skimming", "answering your recent replies faster than they can be read"];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function frictionFact(sitting: MoodTurn[]): [string, string] | null {
|
|
80
|
+
const rough = (t: MoodTurn) => t.corrected || t.repeated || t.interrupted;
|
|
81
|
+
if (countOf(sitting.slice(-4), rough) < 2) return null;
|
|
82
|
+
return ["friction", "correcting, repeating or interrupting in recent turns"];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readMood(turns: MoodTurn[], base: Baseline): Mood {
|
|
86
|
+
const sitting = currentSitting(turns);
|
|
87
|
+
const found = [
|
|
88
|
+
paceFact(sitting, base),
|
|
89
|
+
lengthFact(sitting, base),
|
|
90
|
+
skimFact(sitting),
|
|
91
|
+
frictionFact(sitting),
|
|
92
|
+
].filter((f): f is [string, string] => f !== null);
|
|
93
|
+
return { key: found.map(([label]) => label).join(","), facts: found.map(([, f]) => f) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Said only when the picture changes, including once when it returns to normal. */
|
|
97
|
+
export function moodReminder(mood: Mood, previousKey: string): string | null {
|
|
98
|
+
if (mood.key === previousKey) return null;
|
|
99
|
+
const text = mood.key
|
|
100
|
+
? `Interaction, measured over this sitting: the user is ${mood.facts.join("; ")}. Adapt how you reply to this.`
|
|
101
|
+
: "Interaction: the user's recent turns are back to their usual pattern.";
|
|
102
|
+
return `<system-reminder>${text}</system-reminder>`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measures every user turn in code and logs the features, never the text.
|
|
3
|
+
* Per-session tracking lives in state and is discarded with the session.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { currentAttribution } from "./actor";
|
|
9
|
+
import {
|
|
10
|
+
type Baseline,
|
|
11
|
+
DEFAULT_BASELINE,
|
|
12
|
+
type MoodTurn,
|
|
13
|
+
median,
|
|
14
|
+
moodReminder,
|
|
15
|
+
readMood,
|
|
16
|
+
} from "./interaction-mood";
|
|
17
|
+
import { ensureDir, paths } from "./paths";
|
|
18
|
+
import { isSystemText, stripInjectedTags } from "./prompt-text";
|
|
19
|
+
import { isEnabled } from "./settings";
|
|
20
|
+
import { extractKeywords, similarity } from "./text-similarity";
|
|
21
|
+
import { localClock } from "./wall-clock";
|
|
22
|
+
|
|
23
|
+
interface ReplyShape {
|
|
24
|
+
at: string;
|
|
25
|
+
words: number;
|
|
26
|
+
listItems: number;
|
|
27
|
+
headings: number;
|
|
28
|
+
asked: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TurnEvent extends MoodTurn {
|
|
32
|
+
ts: string;
|
|
33
|
+
session: string;
|
|
34
|
+
channel: string | null;
|
|
35
|
+
hour: number;
|
|
36
|
+
weekday: string;
|
|
37
|
+
reply: Omit<ReplyShape, "at"> | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SessionTrack {
|
|
41
|
+
updated: string;
|
|
42
|
+
lastPrompt?: string;
|
|
43
|
+
lastPromptAt?: string;
|
|
44
|
+
reply?: ReplyShape;
|
|
45
|
+
recent: TurnEvent[];
|
|
46
|
+
mood?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const BREAK_SEC = 20 * 60;
|
|
50
|
+
const SKIM_MIN_WORDS = 150;
|
|
51
|
+
const SKIM_WORDS_PER_SEC = 10;
|
|
52
|
+
const REPEAT_SIMILARITY = 0.6;
|
|
53
|
+
const REPEAT_MIN_KEYWORDS = 5;
|
|
54
|
+
const RECENT_KEPT = 12;
|
|
55
|
+
const MAX_TRACKED_SESSIONS = 20;
|
|
56
|
+
const BASELINE_MIN_SAMPLES = 20;
|
|
57
|
+
|
|
58
|
+
const CORRECTION_RE =
|
|
59
|
+
/^(?:(?:no|nope)(?:[,.!]|$)|wrong\b|that'?s not\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b)/i;
|
|
60
|
+
|
|
61
|
+
function wordCount(text: string): number {
|
|
62
|
+
return text.split(/\s+/).filter(Boolean).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function replyShape(text: string, at: Date): ReplyShape {
|
|
66
|
+
const lines = text.split("\n");
|
|
67
|
+
return {
|
|
68
|
+
at: at.toISOString(),
|
|
69
|
+
words: wordCount(text),
|
|
70
|
+
listItems: lines.filter((l) => /^\s*(?:[-*]|\d+\.)\s/.test(l)).length,
|
|
71
|
+
headings: lines.filter((l) => /^#{1,6}\s/.test(l)).length,
|
|
72
|
+
asked: text.trim().endsWith("?"),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isRepeat(text: string, previous: string | undefined): boolean {
|
|
77
|
+
if (!previous || extractKeywords(text).size < REPEAT_MIN_KEYWORDS) return false;
|
|
78
|
+
return similarity(text, previous) >= REPEAT_SIMILARITY;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The reply belongs to this turn only if it arrived after the previous prompt. */
|
|
82
|
+
function answeredReply(track: SessionTrack): ReplyShape | null {
|
|
83
|
+
const reply = track.reply;
|
|
84
|
+
if (!reply || !track.lastPromptAt) return null;
|
|
85
|
+
return reply.at >= track.lastPromptAt ? reply : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSkimmed(reply: ReplyShape | null, gapSec: number | null): boolean {
|
|
89
|
+
if (!reply || gapSec === null || gapSec <= 0 || reply.words < SKIM_MIN_WORDS)
|
|
90
|
+
return false;
|
|
91
|
+
return reply.words / gapSec > SKIM_WORDS_PER_SEC;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function replyFeatures({ at: _at, ...features }: ReplyShape): Omit<ReplyShape, "at"> {
|
|
95
|
+
return features;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function measureTurn(
|
|
99
|
+
text: string,
|
|
100
|
+
session: string,
|
|
101
|
+
track: SessionTrack,
|
|
102
|
+
now: Date,
|
|
103
|
+
channel: string | null
|
|
104
|
+
): TurnEvent {
|
|
105
|
+
const reply = answeredReply(track);
|
|
106
|
+
const gapSec = reply ? (now.getTime() - new Date(reply.at).getTime()) / 1000 : null;
|
|
107
|
+
return {
|
|
108
|
+
ts: now.toISOString(),
|
|
109
|
+
session,
|
|
110
|
+
channel,
|
|
111
|
+
...localClock(now),
|
|
112
|
+
words: wordCount(text),
|
|
113
|
+
gapSec,
|
|
114
|
+
afterBreak: gapSec !== null && gapSec > BREAK_SEC,
|
|
115
|
+
skimmed: isSkimmed(reply, gapSec),
|
|
116
|
+
interrupted: Boolean(track.lastPromptAt) && !reply,
|
|
117
|
+
repeated: isRepeat(text, track.lastPrompt),
|
|
118
|
+
corrected: CORRECTION_RE.test(text),
|
|
119
|
+
reply: reply ? replyFeatures(reply) : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function eventsDir(): string {
|
|
124
|
+
return ensureDir(resolve(paths.signals(), "interaction"));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function monthFile(date: Date): string {
|
|
128
|
+
return resolve(eventsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendEvent(event: TurnEvent): void {
|
|
132
|
+
const line = { ...currentAttribution(), type: "turn", ...event };
|
|
133
|
+
appendFileSync(monthFile(new Date(event.ts)), `${JSON.stringify(line)}\n`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readEvents(file: string): TurnEvent[] {
|
|
137
|
+
if (!existsSync(file)) return [];
|
|
138
|
+
return readFileSync(file, "utf-8")
|
|
139
|
+
.split("\n")
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.flatMap((line) => {
|
|
142
|
+
try {
|
|
143
|
+
return [JSON.parse(line) as TurnEvent];
|
|
144
|
+
} catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The user's own normal, from this month and last, outside the session being judged. */
|
|
151
|
+
function baseline(session: string, now: Date = new Date()): Baseline {
|
|
152
|
+
const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
|
153
|
+
const events = [
|
|
154
|
+
...readEvents(monthFile(lastMonth)),
|
|
155
|
+
...readEvents(monthFile(now)),
|
|
156
|
+
].filter((e) => e.session !== session);
|
|
157
|
+
const gaps = events
|
|
158
|
+
.filter((e) => e.gapSec !== null && !e.afterBreak)
|
|
159
|
+
.map((e) => e.gapSec);
|
|
160
|
+
return {
|
|
161
|
+
words:
|
|
162
|
+
events.length >= BASELINE_MIN_SAMPLES
|
|
163
|
+
? (median(events.map((e) => e.words)) as number)
|
|
164
|
+
: DEFAULT_BASELINE.words,
|
|
165
|
+
gapSec:
|
|
166
|
+
gaps.length >= BASELINE_MIN_SAMPLES
|
|
167
|
+
? (median(gaps as number[]) as number)
|
|
168
|
+
: DEFAULT_BASELINE.gapSec,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function tracksFile(): string {
|
|
173
|
+
return resolve(paths.state(), "interaction-sessions.json");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readTracks(): Record<string, SessionTrack> {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(readFileSync(tracksFile(), "utf-8"));
|
|
179
|
+
} catch {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function writeTrack(session: string, track: SessionTrack): void {
|
|
185
|
+
const tracks = { ...readTracks(), [session]: track };
|
|
186
|
+
const kept = Object.entries(tracks)
|
|
187
|
+
.sort(([, a], [, b]) => b.updated.localeCompare(a.updated))
|
|
188
|
+
.slice(0, MAX_TRACKED_SESSIONS);
|
|
189
|
+
writeFileSync(tracksFile(), JSON.stringify(Object.fromEntries(kept)), "utf-8");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function trackOf(session: string): SessionTrack {
|
|
193
|
+
return readTracks()[session] ?? { updated: "", recent: [] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Called when the agent finishes a reply, so the next turn can be measured against it. */
|
|
197
|
+
export function recordReply(
|
|
198
|
+
session: string,
|
|
199
|
+
reply: string,
|
|
200
|
+
now: Date = new Date()
|
|
201
|
+
): void {
|
|
202
|
+
if (!session || !reply.trim() || !isEnabled("interactionAwareness")) return;
|
|
203
|
+
const track = trackOf(session);
|
|
204
|
+
writeTrack(session, {
|
|
205
|
+
...track,
|
|
206
|
+
updated: now.toISOString(),
|
|
207
|
+
reply: replyShape(reply, now),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Logs the turn and returns a reminder when the session's picture changed. */
|
|
212
|
+
export function observeTurn(
|
|
213
|
+
prompt: string,
|
|
214
|
+
session: string | undefined,
|
|
215
|
+
now: Date = new Date(),
|
|
216
|
+
channel: string | null = process.env.PAL_CHANNEL || null
|
|
217
|
+
): string | null {
|
|
218
|
+
if (!session || isSystemText(prompt) || !isEnabled("interactionAwareness")) return null;
|
|
219
|
+
const text = stripInjectedTags(prompt);
|
|
220
|
+
if (!text) return null;
|
|
221
|
+
const track = trackOf(session);
|
|
222
|
+
const event = measureTurn(text, session, track, now, channel);
|
|
223
|
+
appendEvent(event);
|
|
224
|
+
const recent = [...track.recent, event].slice(-RECENT_KEPT);
|
|
225
|
+
const mood = readMood(recent, baseline(session, now));
|
|
226
|
+
writeTrack(session, {
|
|
227
|
+
...track,
|
|
228
|
+
updated: now.toISOString(),
|
|
229
|
+
lastPrompt: text,
|
|
230
|
+
lastPromptAt: now.toISOString(),
|
|
231
|
+
recent,
|
|
232
|
+
mood: mood.key,
|
|
233
|
+
});
|
|
234
|
+
return moodReminder(mood, track.mood ?? "");
|
|
235
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prompt field carries more than the user typed: IDE tags, reminders, task
|
|
3
|
+
* notifications and continuation boilerplate arrive through the same hook.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const INJECTED_TAG_RE =
|
|
7
|
+
/<(?:ide_opened_file|ide_selection|system-reminder|task-notification)[^>]*>[\s\S]*?<\/(?:ide_opened_file|ide_selection|system-reminder|task-notification)>/gi;
|
|
8
|
+
|
|
9
|
+
export function stripInjectedTags(prompt: string): string {
|
|
10
|
+
return prompt.replace(INJECTED_TAG_RE, "").trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SYSTEM_TEXT_PATTERNS = [
|
|
14
|
+
/^<task-notification>/i,
|
|
15
|
+
/^<system-reminder>/i,
|
|
16
|
+
/^This session is being continued from a previous conversation/i,
|
|
17
|
+
/^Please continue the conversation/i,
|
|
18
|
+
/^Note:.*was read before/i,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function isSystemText(prompt: string): boolean {
|
|
22
|
+
const trimmed = prompt.trim();
|
|
23
|
+
return SYSTEM_TEXT_PATTERNS.some((re) => re.test(trimmed));
|
|
24
|
+
}
|
|
@@ -102,10 +102,12 @@ export function loadRecentNotes(days: number = 2): string {
|
|
|
102
102
|
const cutoff = new Date();
|
|
103
103
|
cutoff.setDate(cutoff.getDate() - days);
|
|
104
104
|
|
|
105
|
+
const cutoffMonth = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}`;
|
|
105
106
|
const sections: string[] = [];
|
|
106
107
|
|
|
107
108
|
for (const monthDir of readdirSync(relDir).sort().reverse()) {
|
|
108
109
|
if (!/^\d{4}-\d{2}$/.test(monthDir)) continue;
|
|
110
|
+
if (monthDir < cutoffMonth) break;
|
|
109
111
|
const monthPath = resolve(relDir, monthDir);
|
|
110
112
|
|
|
111
113
|
let files: string[];
|
|
@@ -129,8 +131,6 @@ export function loadRecentNotes(days: number = 2): string {
|
|
|
129
131
|
// skip unreadable files
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
|
-
|
|
133
|
-
if (sections.length > 0) break; // only go back one month at most
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
return sections.join("\n\n---\n\n");
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -20,9 +20,15 @@ import { resetTab } from "../handlers/tab";
|
|
|
20
20
|
import { updateCounts } from "../handlers/update-counts";
|
|
21
21
|
import { captureWorkSession } from "../handlers/work-session";
|
|
22
22
|
import { spawnDetachedInference } from "./detached-inference";
|
|
23
|
+
import { recordReply } from "./interaction";
|
|
23
24
|
import { logDebug, logError } from "./log";
|
|
24
25
|
import { assets, ensureDir, paths } from "./paths";
|
|
25
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
extractContent,
|
|
28
|
+
extractLastAssistant,
|
|
29
|
+
parseMessages,
|
|
30
|
+
withFinalReply,
|
|
31
|
+
} from "./transcript";
|
|
26
32
|
|
|
27
33
|
interface RunStopHandlersOptions {
|
|
28
34
|
lastAssistantMessage?: string;
|
|
@@ -31,16 +37,19 @@ interface RunStopHandlersOptions {
|
|
|
31
37
|
|
|
32
38
|
/** Run all stop handlers with a transcript string */
|
|
33
39
|
export async function runStopHandlers(
|
|
34
|
-
|
|
40
|
+
fileTranscript: string,
|
|
35
41
|
options: RunStopHandlersOptions = {}
|
|
36
42
|
): Promise<void> {
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
43
|
+
const fileMessages = parseMessages(fileTranscript);
|
|
44
|
+
if (fileMessages.length < 2) return;
|
|
45
|
+
const messages = withFinalReply(fileMessages, options.lastAssistantMessage);
|
|
46
|
+
const transcript = JSON.stringify(messages);
|
|
39
47
|
|
|
40
48
|
logDebug("runStopHandlers", `Running handlers (${messages.length} messages)`);
|
|
41
49
|
|
|
42
50
|
// Cache last assistant response (session-scoped)
|
|
43
51
|
cacheLastResponse(messages, options.lastAssistantMessage, options.sessionId);
|
|
52
|
+
if (options.sessionId) recordFinalReply(messages, options.sessionId);
|
|
44
53
|
|
|
45
54
|
// Always persist last exchange — drives CompactRecover + "Pick Up Where You Left Off"
|
|
46
55
|
if (options.sessionId) persistLastExchange(messages, options.sessionId);
|
|
@@ -154,6 +163,17 @@ function cacheLastResponse(
|
|
|
154
163
|
}
|
|
155
164
|
}
|
|
156
165
|
|
|
166
|
+
function recordFinalReply(
|
|
167
|
+
messages: ReturnType<typeof parseMessages>,
|
|
168
|
+
sessionId: string
|
|
169
|
+
): void {
|
|
170
|
+
try {
|
|
171
|
+
recordReply(sessionId, extractContent(extractLastAssistant(messages)));
|
|
172
|
+
} catch (err) {
|
|
173
|
+
logError("runStopHandlers:recordReply", err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
157
177
|
/** Write transcript to a fresh tmp file and return the path. Child unlinks it. */
|
|
158
178
|
async function writeTranscriptTmp(transcript: string): Promise<string> {
|
|
159
179
|
const dir = await mkdtemp(resolve(tmpdir(), "pal-transcript-"));
|
|
@@ -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();
|
|
@@ -57,6 +57,12 @@ export function localDay(now: Date = new Date()): string {
|
|
|
57
57
|
return `${f.year}-${f.month}-${f.day}`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Hour (0-23) and short weekday in the principal's timezone. */
|
|
61
|
+
export function localClock(now: Date = new Date()): { hour: number; weekday: string } {
|
|
62
|
+
const f = fieldsIn(now, zoneOrUtc(identity().principal.timezone));
|
|
63
|
+
return { hour: Number(f.hour), weekday: f.weekday };
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
export function getWallClockReminder(now: Date = new Date()): string | null {
|
|
61
67
|
if (!isEnabled("wallClock")) return null;
|
|
62
68
|
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 */
|
|
@@ -175,7 +175,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
175
175
|
const text = partsToText(output.parts ?? []);
|
|
176
176
|
if (!text.trim()) return;
|
|
177
177
|
|
|
178
|
-
const injectedText = (await getPromptContext(text)) ?? "";
|
|
178
|
+
const injectedText = (await getPromptContext(text, input.sessionID)) ?? "";
|
|
179
179
|
logPromptSnapshot(text, injectedText || null);
|
|
180
180
|
|
|
181
181
|
await Promise.allSettled([
|