pi-observational-memory 2.4.2 → 3.0.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/README.md +316 -79
- package/package.json +10 -9
- package/src/agents/dropper/agent.ts +152 -0
- package/src/agents/dropper/pool.ts +67 -0
- package/src/agents/dropper/prompts.ts +43 -0
- package/src/{observer.ts → agents/observer/agent.ts} +30 -16
- package/src/agents/observer/prompts.ts +119 -0
- package/src/agents/reflector/agent.ts +134 -0
- package/src/agents/reflector/prompts.ts +77 -0
- package/src/clipboard.ts +63 -0
- package/src/commands/status.ts +79 -78
- package/src/commands/view.ts +58 -66
- package/src/config.ts +95 -16
- package/src/debug-log.ts +49 -0
- package/src/hooks/compaction-hook.ts +28 -184
- package/src/hooks/compaction-trigger.ts +35 -21
- package/src/hooks/consolidation-trigger.ts +331 -0
- package/src/index.ts +3 -3
- package/src/model-budget.ts +9 -0
- package/src/runtime.ts +46 -19
- package/src/serialize.ts +1 -1
- package/src/session-ledger/fold.ts +100 -0
- package/src/session-ledger/index.ts +6 -0
- package/src/session-ledger/progress.ts +129 -0
- package/src/session-ledger/projection.ts +220 -0
- package/src/session-ledger/recall.ts +237 -0
- package/src/session-ledger/render-summary.ts +31 -0
- package/src/session-ledger/types.ts +200 -0
- package/src/tokens.ts +1 -1
- package/src/tools/recall-observation.ts +84 -214
- package/src/branch.ts +0 -577
- package/src/compaction.ts +0 -617
- package/src/hooks/observer-trigger.ts +0 -96
- package/src/prompts.ts +0 -301
- package/src/relevance.ts +0 -15
- package/src/types.ts +0 -155
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { Static } from "typebox";
|
|
5
|
+
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
6
|
+
import { observationToSummaryLine, reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
|
|
7
|
+
import { DROPPER_SYSTEM } from "./prompts.js";
|
|
8
|
+
import { observationPoolMetrics } from "./pool.js";
|
|
9
|
+
export {
|
|
10
|
+
maxDropCountForPool,
|
|
11
|
+
observationPoolFullness,
|
|
12
|
+
observationPoolMetrics,
|
|
13
|
+
} from "./pool.js";
|
|
14
|
+
export type { ObservationPoolMetrics } from "./pool.js";
|
|
15
|
+
|
|
16
|
+
interface RunDropperArgs {
|
|
17
|
+
model: Model<any>;
|
|
18
|
+
apiKey: string;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
reflections: Reflection[];
|
|
21
|
+
observations: Observation[];
|
|
22
|
+
targetTokens: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
agentLoop?: typeof agentLoop;
|
|
25
|
+
maxTurns?: number;
|
|
26
|
+
thinkingLevel?: ModelThinkingLevel;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RELEVANCE_DROP_RANK: Record<Observation["relevance"], number> = {
|
|
30
|
+
low: 0,
|
|
31
|
+
medium: 1,
|
|
32
|
+
high: 2,
|
|
33
|
+
critical: 3,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const DropObservationsSchema = Type.Object({
|
|
37
|
+
ids: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
38
|
+
reason: Type.Optional(Type.String()),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
type DropObservationsArgs = Static<typeof DropObservationsSchema>;
|
|
42
|
+
|
|
43
|
+
function joinOrEmpty(items: string[]): string {
|
|
44
|
+
return items.length ? items.join("\n") : "(none yet)";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeDropObservationIds(
|
|
48
|
+
ids: readonly string[] | undefined,
|
|
49
|
+
observations: readonly Observation[],
|
|
50
|
+
): string[] | undefined {
|
|
51
|
+
if (!ids || ids.length === 0) return undefined;
|
|
52
|
+
const allowed = new Map(observations.map((observation) => [observation.id, observation]));
|
|
53
|
+
const result: string[] = [];
|
|
54
|
+
const seen = new Set<string>();
|
|
55
|
+
for (const id of ids) {
|
|
56
|
+
const observation = allowed.get(id);
|
|
57
|
+
if (!observation) continue;
|
|
58
|
+
if (observation.relevance === "critical") continue;
|
|
59
|
+
if (seen.has(id)) continue;
|
|
60
|
+
seen.add(id);
|
|
61
|
+
result.push(id);
|
|
62
|
+
}
|
|
63
|
+
return result.length > 0 ? result : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function selectDropCandidates(
|
|
67
|
+
ids: readonly string[],
|
|
68
|
+
observations: readonly Observation[],
|
|
69
|
+
maxDrops: number,
|
|
70
|
+
): string[] {
|
|
71
|
+
if (maxDrops <= 0 || ids.length === 0) return [];
|
|
72
|
+
|
|
73
|
+
const byId = new Map(observations.map((observation) => [observation.id, observation]));
|
|
74
|
+
const firstProposalIndex = new Map<string, number>();
|
|
75
|
+
for (let i = 0; i < ids.length; i++) {
|
|
76
|
+
const id = ids[i];
|
|
77
|
+
if (!firstProposalIndex.has(id)) firstProposalIndex.set(id, i);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Array.from(firstProposalIndex.entries())
|
|
81
|
+
.map(([id, index]) => ({ id, index, observation: byId.get(id) }))
|
|
82
|
+
.filter((candidate): candidate is { id: string; index: number; observation: Observation } =>
|
|
83
|
+
candidate.observation !== undefined && candidate.observation.relevance !== "critical"
|
|
84
|
+
)
|
|
85
|
+
.sort((a, b) => {
|
|
86
|
+
const relevanceDelta = RELEVANCE_DROP_RANK[a.observation.relevance] - RELEVANCE_DROP_RANK[b.observation.relevance];
|
|
87
|
+
return relevanceDelta || a.index - b.index;
|
|
88
|
+
})
|
|
89
|
+
.slice(0, maxDrops)
|
|
90
|
+
.map((candidate) => candidate.id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function runDropper(args: RunDropperArgs): Promise<string[] | undefined> {
|
|
94
|
+
const { model, apiKey, headers, reflections, observations, targetTokens, signal } = args;
|
|
95
|
+
if (observations.length === 0) return undefined;
|
|
96
|
+
|
|
97
|
+
const metrics = observationPoolMetrics(observations, targetTokens);
|
|
98
|
+
const { observationTokens, fullness, tokensOverTarget, maxDropsAllowed } = metrics;
|
|
99
|
+
if (maxDropsAllowed <= 0) return undefined;
|
|
100
|
+
|
|
101
|
+
const proposedDropIds: string[] = [];
|
|
102
|
+
const proposed = new Set<string>();
|
|
103
|
+
|
|
104
|
+
const dropObservations: AgentTool<typeof DropObservationsSchema> = {
|
|
105
|
+
name: "drop_observations",
|
|
106
|
+
label: "Drop observations",
|
|
107
|
+
description: "Propose active observation ids that are safe to remove from compacted memory.",
|
|
108
|
+
parameters: DropObservationsSchema,
|
|
109
|
+
execute: async (_id, params: DropObservationsArgs) => {
|
|
110
|
+
const normalized = normalizeDropObservationIds(params.ids, observations) ?? [];
|
|
111
|
+
let added = 0;
|
|
112
|
+
for (const id of normalized) {
|
|
113
|
+
if (proposed.has(id)) continue;
|
|
114
|
+
proposed.add(id);
|
|
115
|
+
proposedDropIds.push(id);
|
|
116
|
+
added++;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text", text: `Queued ${added} drop candidate${added === 1 ? "" : "s"}. Candidates this run: ${proposedDropIds.length}. Maximum drops allowed: ${maxDropsAllowed}.` }],
|
|
120
|
+
details: { added, totalCandidates: proposedDropIds.length, maxDropsAllowed },
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const fullnessPercent = Math.round(fullness * 100);
|
|
126
|
+
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\nCURRENT OBSERVATIONS:\n${joinOrEmpty(observations.map(observationToSummaryLine))}\n\nActive observation pool: ~${observationTokens.toLocaleString()} tokens; target: ~${targetTokens.toLocaleString()} tokens; fullness against target: ~${fullnessPercent.toLocaleString()}%; over target by ~${tokensOverTarget.toLocaleString()} tokens.\nMaximum drops allowed this run: ${maxDropsAllowed.toLocaleString()} observation${maxDropsAllowed === 1 ? "" : "s"}. This maximum is sized to move the active pool toward the target if every proposed drop is clearly safe.\nThis maximum is a hard upper bound, not a target. Drop fewer or none if fewer observations are clearly safe.`;
|
|
127
|
+
const prompts: Message[] = [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }];
|
|
128
|
+
const context: AgentContext = { systemPrompt: DROPPER_SYSTEM, messages: [], tools: [dropObservations as AgentTool<any>] };
|
|
129
|
+
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
130
|
+
const thinkingLevel = args.thinkingLevel ?? "low";
|
|
131
|
+
const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
|
|
132
|
+
let turnCount = 0;
|
|
133
|
+
const config: AgentLoopConfig = {
|
|
134
|
+
model,
|
|
135
|
+
apiKey,
|
|
136
|
+
headers,
|
|
137
|
+
maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
|
|
138
|
+
convertToLlm: (msgs) => msgs as Message[],
|
|
139
|
+
toolExecution: "sequential",
|
|
140
|
+
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
141
|
+
...(effectiveMaxTurns !== undefined ? { shouldStopAfterTurn: () => ++turnCount >= effectiveMaxTurns } : {}),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const loop = args.agentLoop ?? agentLoop;
|
|
145
|
+
const stream = loop(prompts, context, config, signal);
|
|
146
|
+
for await (const _event of stream) {
|
|
147
|
+
// Tool execution collects candidate ids.
|
|
148
|
+
}
|
|
149
|
+
await stream.result();
|
|
150
|
+
const droppedIds = selectDropCandidates(proposedDropIds, observations, maxDropsAllowed);
|
|
151
|
+
return droppedIds.length > 0 ? droppedIds : undefined;
|
|
152
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Observation } from "../../session-ledger/index.js";
|
|
2
|
+
|
|
3
|
+
export type ObservationPoolMetrics = {
|
|
4
|
+
observationTokens: number;
|
|
5
|
+
targetTokens: number;
|
|
6
|
+
tokensOverTarget: number;
|
|
7
|
+
fullness: number;
|
|
8
|
+
activeObservationCount: number;
|
|
9
|
+
droppableCount: number;
|
|
10
|
+
maxDropsAllowed: number;
|
|
11
|
+
overTarget: boolean;
|
|
12
|
+
ready: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function observationTokenSum(observations: readonly { tokenCount: number }[]): number {
|
|
16
|
+
return observations.reduce((sum, observation) => sum + observation.tokenCount, 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function observationPoolFullness(observationTokens: number, targetTokens: number): number {
|
|
20
|
+
if (!Number.isFinite(observationTokens) || observationTokens <= 0) return 0;
|
|
21
|
+
if (!Number.isFinite(targetTokens) || targetTokens <= 0) return 0;
|
|
22
|
+
return observationTokens / targetTokens;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function droppableObservationCount(observations: readonly Observation[]): number {
|
|
26
|
+
return observations.filter((observation) => observation.relevance !== "critical").length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function maxDropCountForPool(observations: readonly Observation[], observationTokens: number, targetTokens: number): number {
|
|
30
|
+
const activeObservationCount = observations.length;
|
|
31
|
+
if (activeObservationCount === 0) return 0;
|
|
32
|
+
if (!Number.isFinite(observationTokens) || observationTokens <= 0) return 0;
|
|
33
|
+
if (!Number.isFinite(targetTokens) || targetTokens < 0) return 0;
|
|
34
|
+
|
|
35
|
+
const tokensOverTarget = observationTokens - targetTokens;
|
|
36
|
+
if (tokensOverTarget <= 0) return 0;
|
|
37
|
+
|
|
38
|
+
const averageObservationTokens = observationTokens / activeObservationCount;
|
|
39
|
+
if (!Number.isFinite(averageObservationTokens) || averageObservationTokens <= 0) return 0;
|
|
40
|
+
|
|
41
|
+
const estimatedDrops = Math.ceil(tokensOverTarget / averageObservationTokens);
|
|
42
|
+
return Math.min(activeObservationCount, Math.max(1, estimatedDrops));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function observationPoolMetrics(
|
|
46
|
+
observations: readonly Observation[],
|
|
47
|
+
targetTokens: number,
|
|
48
|
+
): ObservationPoolMetrics {
|
|
49
|
+
const observationTokens = observationTokenSum(observations);
|
|
50
|
+
const fullness = observationPoolFullness(observationTokens, targetTokens);
|
|
51
|
+
const activeObservationCount = observations.length;
|
|
52
|
+
const droppableCount = droppableObservationCount(observations);
|
|
53
|
+
const tokensOverTarget = Math.max(0, observationTokens - targetTokens);
|
|
54
|
+
const maxDropsAllowed = maxDropCountForPool(observations, observationTokens, targetTokens);
|
|
55
|
+
const overTarget = Number.isFinite(targetTokens) && targetTokens >= 0 && observationTokens > targetTokens;
|
|
56
|
+
return {
|
|
57
|
+
observationTokens,
|
|
58
|
+
targetTokens,
|
|
59
|
+
tokensOverTarget,
|
|
60
|
+
fullness,
|
|
61
|
+
activeObservationCount,
|
|
62
|
+
droppableCount,
|
|
63
|
+
maxDropsAllowed,
|
|
64
|
+
overTarget,
|
|
65
|
+
ready: overTarget && maxDropsAllowed > 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const DROPPER_SYSTEM = `You are the dropper agent for a coding assistant.
|
|
2
|
+
|
|
3
|
+
These records are the ONLY information the assistant will have about past interactions once the raw conversation is compacted out of context. Dropping the wrong observation can make future work repeat, contradict, or misremember the user. Take this seriously.
|
|
4
|
+
|
|
5
|
+
Your job is to identify only the safest active observations to remove from compacted memory by calling drop_observations with their ids. Default action is KEEP. When uncertain, keep the observation.
|
|
6
|
+
|
|
7
|
+
Active-memory framing. Dropping an observation removes it from active compacted memory; it does not erase the ledger history or source evidence. Still, future compressed context will no longer show the observation, so only drop it when its durable meaning is safely captured elsewhere or it is genuinely low-signal and carries no unique future value.
|
|
8
|
+
|
|
9
|
+
The user message includes the active observation pool target and "Maximum drops allowed this run". The maximum is a hard upper bound sized to move the pool toward the target if every proposed drop is clearly safe. It is not a target. Never try to hit it. Drop fewer or none when fewer observations are safely removable.
|
|
10
|
+
|
|
11
|
+
What to drop, in priority order:
|
|
12
|
+
- Redundant observations whose durable meaning is already captured by current reflections with equivalent fidelity.
|
|
13
|
+
- Superseded observations where a later observation clearly replaces the older state.
|
|
14
|
+
- Repeated routine tool acknowledgements or low-signal progress updates that do not carry decisions, constraints, exact errors, or user-specific facts.
|
|
15
|
+
- Older medium observations that no longer carry working context and are covered by a reflection or a newer observation.
|
|
16
|
+
|
|
17
|
+
Age-gradient rule. Recent observations carry working context the assistant may still need; older observations have usually been summarized elsewhere or are no longer load-bearing. Prefer older safe drops before newer working context.
|
|
18
|
+
|
|
19
|
+
Relevance guidance:
|
|
20
|
+
- low: consider first, but drop only when it carries no unique detail, decision, state, error, identifier, or user-specific fact.
|
|
21
|
+
- medium: drop when redundant with reflections or other observations, or when the work state is clearly obsolete.
|
|
22
|
+
- high: drop only when clearly superseded or already captured by a reflection with equivalent fidelity.
|
|
23
|
+
- critical: NEVER drop. Code also rejects critical ids, but you must avoid proposing them.
|
|
24
|
+
|
|
25
|
+
User assertions and concrete completions are never droppable, even at non-critical relevance, unless a current reflection preserves the exact assertion/completion and its important details with equivalent fidelity.
|
|
26
|
+
|
|
27
|
+
Preservation floor. Regardless of relevance label, urgency, budget pressure, or age, do not drop observations that uniquely carry any of the following:
|
|
28
|
+
- User preferences, constraints, corrections, or identity/role facts.
|
|
29
|
+
- Concrete completions that future runs must not redo.
|
|
30
|
+
- Named identifiers, file paths, function names, package names, tickets, commit SHAs, handles, or exact commands.
|
|
31
|
+
- Exact error messages, diagnostic output, or test failure names.
|
|
32
|
+
- Architectural or technical decisions and their rationale.
|
|
33
|
+
- Dates of specific events, deadlines, meetings, migrations, or incidents.
|
|
34
|
+
- Current unresolved blockers, TODOs, partial work, or decisions waiting on the user.
|
|
35
|
+
- Non-standard user terminology or unusual phrasing needed for future recognition.
|
|
36
|
+
|
|
37
|
+
What you cannot do:
|
|
38
|
+
- You cannot merge observations.
|
|
39
|
+
- You cannot rewrite or edit observations.
|
|
40
|
+
- You cannot add new observations or reflections.
|
|
41
|
+
- You can only call drop_observations with ids from the current observations list.
|
|
42
|
+
|
|
43
|
+
Do not force drops you do not believe in. If no observations are safe to drop, do not call the tool and reply briefly. Hitting the budget or maximum count is less important than preserving load-bearing memory.`;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@
|
|
2
|
-
import type { Message, Model } from "@
|
|
3
|
-
import { Type } from "@
|
|
4
|
-
import type { Static } from "
|
|
5
|
-
import { hashId } from "
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { Static } from "typebox";
|
|
5
|
+
import { hashId } from "../../ids.js";
|
|
6
|
+
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
6
7
|
import { OBSERVER_SYSTEM } from "./prompts.js";
|
|
7
|
-
import { nowTimestamp, truncateRecordContent } from "
|
|
8
|
-
import type {
|
|
8
|
+
import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
|
|
9
|
+
import type { Observation, Relevance } from "../../session-ledger/index.js";
|
|
10
|
+
import { estimateStringTokens } from "../../tokens.js";
|
|
9
11
|
|
|
10
12
|
interface RunObserverArgs {
|
|
11
13
|
model: Model<any>;
|
|
@@ -16,6 +18,9 @@ interface RunObserverArgs {
|
|
|
16
18
|
chunk: string;
|
|
17
19
|
allowedSourceEntryIds: string[];
|
|
18
20
|
signal?: AbortSignal;
|
|
21
|
+
agentLoop?: typeof agentLoop;
|
|
22
|
+
maxTurns?: number;
|
|
23
|
+
thinkingLevel?: ModelThinkingLevel;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
const RelevanceSchema = Type.Union([
|
|
@@ -76,12 +81,12 @@ export function normalizeSourceEntryIds(
|
|
|
76
81
|
return Array.from(seen).sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
export async function runObserver(args: RunObserverArgs): Promise<
|
|
84
|
+
export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
|
|
80
85
|
const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
81
86
|
const conversation = chunk.trim();
|
|
82
87
|
if (!conversation) return undefined;
|
|
83
88
|
|
|
84
|
-
const accumulated = new Map<string,
|
|
89
|
+
const accumulated = new Map<string, Observation>();
|
|
85
90
|
|
|
86
91
|
const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
|
|
87
92
|
name: "record_observations",
|
|
@@ -113,6 +118,7 @@ export async function runObserver(args: RunObserverArgs): Promise<ObservationRec
|
|
|
113
118
|
timestamp: obs.timestamp,
|
|
114
119
|
relevance: obs.relevance as Relevance,
|
|
115
120
|
sourceEntryIds,
|
|
121
|
+
tokenCount: estimateStringTokens(content),
|
|
116
122
|
});
|
|
117
123
|
added++;
|
|
118
124
|
}
|
|
@@ -158,17 +164,29 @@ ${conversation}`;
|
|
|
158
164
|
};
|
|
159
165
|
|
|
160
166
|
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
167
|
+
const thinkingLevel = args.thinkingLevel ?? "low";
|
|
168
|
+
const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
|
|
169
|
+
let turnCount = 0;
|
|
161
170
|
const config: AgentLoopConfig = {
|
|
162
171
|
model,
|
|
163
172
|
apiKey,
|
|
164
173
|
headers,
|
|
165
|
-
maxTokens:
|
|
174
|
+
maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
|
|
166
175
|
convertToLlm: (msgs) => msgs as Message[],
|
|
167
176
|
toolExecution: "sequential",
|
|
168
|
-
...(reasoning ? { reasoning:
|
|
177
|
+
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
178
|
+
...(effectiveMaxTurns !== undefined
|
|
179
|
+
? {
|
|
180
|
+
shouldStopAfterTurn: () => {
|
|
181
|
+
turnCount++;
|
|
182
|
+
return turnCount >= effectiveMaxTurns;
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
: {}),
|
|
169
186
|
};
|
|
170
187
|
|
|
171
|
-
const
|
|
188
|
+
const loop = args.agentLoop ?? agentLoop;
|
|
189
|
+
const stream = loop(prompts, context, config, signal);
|
|
172
190
|
for await (const _event of stream) {
|
|
173
191
|
// Drain events; the tool's execute already collects records.
|
|
174
192
|
}
|
|
@@ -177,7 +195,3 @@ ${conversation}`;
|
|
|
177
195
|
if (accumulated.size === 0) return undefined;
|
|
178
196
|
return Array.from(accumulated.values());
|
|
179
197
|
}
|
|
180
|
-
|
|
181
|
-
export function observationsToPromptLines(records: ObservationRecord[]): string[] {
|
|
182
|
-
return records.map((r) => `[${r.id}] ${r.timestamp} [${r.relevance}] ${r.content}`);
|
|
183
|
-
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const OBSERVER_SYSTEM = `You are the observation agent for a coding assistant.
|
|
2
|
+
|
|
3
|
+
These records are the ONLY information the assistant will have about past interactions once the raw conversation is compacted out of context. Anything you do not capture here will be forgotten. Anything you distort here will be remembered wrong. Take this seriously.
|
|
4
|
+
|
|
5
|
+
Your job is to compress a chunk of recent conversation into timestamped, rated observations by calling the record_observations tool. The observations you emit — together with the reflections crystallized from them — are the assistant's ONLY memory of this session after the raw conversation falls out of context.
|
|
6
|
+
|
|
7
|
+
You receive:
|
|
8
|
+
- Current reflections (long-lived facts already crystallized).
|
|
9
|
+
- Current observations (already-recorded observations, each shown as "[id] YYYY-MM-DD HH:MM [relevance] content").
|
|
10
|
+
- A new chunk of conversation with source entry labels and inline message timestamps. Each source block starts with "[Source entry id: <id>]" followed by content formatted as "[User @ YYYY-MM-DD HH:MM]:", "[Assistant @ ...]:", "[Tool result for <name> @ ...]:", custom messages, or branch summaries.
|
|
11
|
+
- A current local time fallback for observations that have no obvious message timestamp.
|
|
12
|
+
|
|
13
|
+
How you work:
|
|
14
|
+
1. Read reflections and current observations so you know what is already captured.
|
|
15
|
+
2. Read the conversation chunk and identify what new information it contains.
|
|
16
|
+
3. Call record_observations with a batch covering part (or all) of the chunk.
|
|
17
|
+
4. Read the progress receipt. If content remains uncovered, call again. You may call the tool many times.
|
|
18
|
+
5. When the chunk is fully covered, STOP calling the tool and reply with a brief plain-text confirmation (one short sentence). That ends the run.
|
|
19
|
+
|
|
20
|
+
What to emit:
|
|
21
|
+
- Produce NEW observations for the new chunk only. Do not restate facts already present in reflections or current observations unless something has materially changed.
|
|
22
|
+
- Use the timestamp from the relevant conversation message. Fall back to current local time ONLY when no message timestamp applies.
|
|
23
|
+
- For every observation, include sourceEntryIds: the smallest exact set of "[Source entry id: ...]" ids that directly support the observation.
|
|
24
|
+
- Never invent source entry ids. Use only ids printed in the chunk. If an observation spans multiple turns or tool results, include every supporting source entry id.
|
|
25
|
+
- Observations with missing, empty, or invalid sourceEntryIds will be rejected and not recorded, so do not call record_observations until you can cite valid source ids.
|
|
26
|
+
- Group repeated similar tool calls into a single observation rather than one per call.
|
|
27
|
+
- Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, simply do not call the tool and end with a plain-text confirmation.
|
|
28
|
+
|
|
29
|
+
Observation content rules:
|
|
30
|
+
|
|
31
|
+
Format.
|
|
32
|
+
- Single line of plain prose. No markdown, no bullets, no code fences, no XML/HTML tags, no emojis.
|
|
33
|
+
- Do NOT include the timestamp or relevance inside the content string — those are separate fields.
|
|
34
|
+
- No structured fields embedded in the text (no "key: value" lines, no JSON).
|
|
35
|
+
|
|
36
|
+
Preserve user assertions exactly.
|
|
37
|
+
When the user TELLS you something about themselves, their project, or their environment, capture it as an assertion. When the user ASKS something, capture it as a question. Assertions are authoritative — a later question on the same topic does not invalidate them.
|
|
38
|
+
BAD: User wondered if they have two kids.
|
|
39
|
+
GOOD: User stated they have two kids.
|
|
40
|
+
BAD: User discussed auth middleware.
|
|
41
|
+
GOOD: User asked how to configure JWT auth middleware.
|
|
42
|
+
Why this matters: if the user says "I use Postgres" and later asks "what db am I on?", downstream agents must treat the assertion as the answer, not the question.
|
|
43
|
+
|
|
44
|
+
Preserve unusual phrasing.
|
|
45
|
+
When the user uses non-standard terminology, quote their exact words so future runs can recognize the term.
|
|
46
|
+
BAD: User exercised yesterday.
|
|
47
|
+
GOOD: User stated they did a "movement session" (their term) yesterday.
|
|
48
|
+
|
|
49
|
+
Use precise action verbs. Replace vague verbs with ones that clarify the nature of the action.
|
|
50
|
+
BAD: User got a new subscription.
|
|
51
|
+
GOOD: User subscribed to the Pro plan.
|
|
52
|
+
BAD: User stopped getting the newsletter.
|
|
53
|
+
GOOD: User unsubscribed from the newsletter.
|
|
54
|
+
BAD: User got the library.
|
|
55
|
+
GOOD: User installed the zod package via pnpm.
|
|
56
|
+
|
|
57
|
+
Frame state changes as supersession so the old state is explicit.
|
|
58
|
+
BAD: User prefers React Query now.
|
|
59
|
+
GOOD: User will use React Query (switching from SWR).
|
|
60
|
+
Why this matters: without supersession framing, the reflector may crystallize both the old and the new as equally valid preferences.
|
|
61
|
+
|
|
62
|
+
Mark concrete completions explicitly.
|
|
63
|
+
Use "completed:", "resolved:", "confirmed working", or similar phrasing so future runs know not to redo the work.
|
|
64
|
+
BAD: Wrote the login handler.
|
|
65
|
+
GOOD: completed: implemented login handler at src/auth/login.ts; user confirmed tests pass.
|
|
66
|
+
Why this matters: without a completion marker, a later assistant may re-implement work that is already done, wasting the user's time and risking regressions.
|
|
67
|
+
|
|
68
|
+
Split compound statements into separate observations.
|
|
69
|
+
If a single message contains multiple independent facts, intents, or events, emit one observation per fact. One observation per line is what enables downstream retrieval and dropping to operate at fact granularity.
|
|
70
|
+
BAD: User will visit their parents this weekend and needs to clean the garage.
|
|
71
|
+
GOOD: User will visit their parents this weekend. + User stated they need to clean the garage this weekend.
|
|
72
|
+
BAD: User started a new job and is moving to a new apartment next week.
|
|
73
|
+
GOOD: User started a new job. + User will move to a new apartment next week.
|
|
74
|
+
BAD: Assistant recommended Lucia, NextAuth, and Clerk for auth, and user chose Lucia.
|
|
75
|
+
GOOD: Assistant recommended auth libraries: Lucia (session-based, minimal), NextAuth (OAuth-heavy, Next-native), Clerk (hosted, paid). + User chose Lucia.
|
|
76
|
+
Why this matters: a future query like "which auth library did the user pick?" can match a single-fact observation cleanly; a compound observation hides the decision inside a recommendation list.
|
|
77
|
+
|
|
78
|
+
Group repeated similar tool calls into a single observation rather than one per call.
|
|
79
|
+
BAD: Agent viewed src/auth.ts. Agent viewed src/users.ts. Agent viewed src/routes.ts.
|
|
80
|
+
GOOD: Agent surveyed auth-related files (src/auth.ts, src/users.ts, src/routes.ts) and located token validation in src/auth.ts:45.
|
|
81
|
+
|
|
82
|
+
Detail preservation. When an observation references specific things, preserve the distinguishing details so future queries can still find them:
|
|
83
|
+
|
|
84
|
+
- File/location: full path + line number when relevant (src/auth.ts:45, not "the auth file").
|
|
85
|
+
- Identifiers and names: package names, function names, variable names, handles, ticket ids, commit SHAs, error codes. Keep them verbatim.
|
|
86
|
+
- Error messages: quote verbatim.
|
|
87
|
+
BAD: Build failed with a type error.
|
|
88
|
+
GOOD: Build failed: TS2322: Type 'string | undefined' is not assignable to type 'string' at src/auth.ts:47.
|
|
89
|
+
- Numerical results: exact values, units, and direction.
|
|
90
|
+
BAD: Optimization made it faster.
|
|
91
|
+
GOOD: Optimization reduced p95 latency from 420ms to 180ms (57% faster).
|
|
92
|
+
- Quantities and counts: "3 failing tests (auth.test.ts, users.test.ts, routes.test.ts)" not "some failing tests".
|
|
93
|
+
- Recommendation or decision lists: preserve the distinguishing attribute per item.
|
|
94
|
+
BAD: Assistant recommended 3 auth libraries.
|
|
95
|
+
GOOD: Assistant recommended auth libraries: Lucia (session-based, minimal), NextAuth (OAuth-heavy, Next-native), Clerk (hosted, paid).
|
|
96
|
+
- Role / participation: capture the user's role at an event, not just attendance.
|
|
97
|
+
BAD: User worked on the migration.
|
|
98
|
+
GOOD: User led the migration from MySQL to Postgres.
|
|
99
|
+
|
|
100
|
+
If a detail is non-obvious from the code or git history, it belongs in the observation. If it is trivially re-derivable, it does not.
|
|
101
|
+
|
|
102
|
+
Relevance levels (pick one per observation; this field drives future dropping):
|
|
103
|
+
|
|
104
|
+
- critical: user assertions about identity, role, or persistent preferences; explicit corrections ("no, don't do X"); concrete completions that future runs MUST NOT redo. These are load-bearing and will NEVER be dropped. Why this matters: if a "critical" item is lost, the assistant may redo finished work, contradict a correction, or misrepresent who the user is.
|
|
105
|
+
- high: non-trivial technical decisions, architectural direction, unresolved blockers, key constraints. Worth keeping across many compactions.
|
|
106
|
+
- medium: task-level context that helps within the current work but isn't durable. The default when you are unsure between medium and high.
|
|
107
|
+
- low: routine tool-call acks, repetitive status updates, content trivially re-derivable from recent messages. The dropper will drop these first.
|
|
108
|
+
|
|
109
|
+
Do NOT default to "critical" or "high". Most observations are medium or low. Reserve "critical" for things that would cause real damage if forgotten.
|
|
110
|
+
|
|
111
|
+
BAD: relevance=critical for "Agent ran tests and they passed."
|
|
112
|
+
GOOD: relevance=low for "Agent ran tests and they passed." (routine; captured by a completion observation if it matters)
|
|
113
|
+
|
|
114
|
+
BAD: relevance=medium for "User said they are colorblind; red/green indicators do not work for them."
|
|
115
|
+
GOOD: relevance=critical for "User said they are colorblind; red/green indicators do not work for them." (persistent constraint; forgetting it causes real harm)
|
|
116
|
+
|
|
117
|
+
Timestamp format: "YYYY-MM-DD HH:MM" (local time, 24-hour, to the minute). This goes in the timestamp field, not the content.
|
|
118
|
+
|
|
119
|
+
Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count.`;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { Static } from "typebox";
|
|
5
|
+
import { hashId } from "../../ids.js";
|
|
6
|
+
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
7
|
+
import { truncateRecordContent } from "../../serialize.js";
|
|
8
|
+
import { REFLECTOR_SYSTEM } from "./prompts.js";
|
|
9
|
+
import { estimateStringTokens } from "../../tokens.js";
|
|
10
|
+
import { observationToSummaryLine, reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
|
|
11
|
+
|
|
12
|
+
interface RunReflectorArgs {
|
|
13
|
+
model: Model<any>;
|
|
14
|
+
apiKey: string;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
reflections: Reflection[];
|
|
17
|
+
observations: Observation[];
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
agentLoop?: typeof agentLoop;
|
|
20
|
+
maxTurns?: number;
|
|
21
|
+
thinkingLevel?: ModelThinkingLevel;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RecordReflectionsSchema = Type.Object({
|
|
25
|
+
reflections: Type.Array(
|
|
26
|
+
Type.Object({
|
|
27
|
+
content: Type.String({ minLength: 1 }),
|
|
28
|
+
supportingObservationIds: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
29
|
+
}),
|
|
30
|
+
{ minItems: 1 },
|
|
31
|
+
),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
type RecordReflectionsArgs = Static<typeof RecordReflectionsSchema>;
|
|
35
|
+
|
|
36
|
+
function joinOrEmpty(items: string[]): string {
|
|
37
|
+
return items.length ? items.join("\n") : "(none yet)";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeSupportingObservationIds(
|
|
41
|
+
supportingObservationIds: readonly string[] | undefined,
|
|
42
|
+
allowedObservationIds: readonly string[],
|
|
43
|
+
): string[] | undefined {
|
|
44
|
+
if (!supportingObservationIds || supportingObservationIds.length === 0) return undefined;
|
|
45
|
+
const allowedOrder = new Map<string, number>();
|
|
46
|
+
for (let i = 0; i < allowedObservationIds.length; i++) {
|
|
47
|
+
if (!allowedOrder.has(allowedObservationIds[i])) allowedOrder.set(allowedObservationIds[i], i);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
for (const id of supportingObservationIds) {
|
|
52
|
+
if (!allowedOrder.has(id)) return undefined;
|
|
53
|
+
seen.add(id);
|
|
54
|
+
}
|
|
55
|
+
if (seen.size === 0) return undefined;
|
|
56
|
+
return Array.from(seen).sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeReflectionContent(content: string): string | undefined {
|
|
60
|
+
const normalized = truncateRecordContent(content.trim());
|
|
61
|
+
if (!normalized || /\r|\n/.test(normalized)) return undefined;
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runReflector(args: RunReflectorArgs): Promise<Reflection[] | undefined> {
|
|
66
|
+
const { model, apiKey, headers, reflections, observations, signal } = args;
|
|
67
|
+
if (observations.length === 0) return undefined;
|
|
68
|
+
|
|
69
|
+
const allowedObservationIds = observations.map((observation) => observation.id);
|
|
70
|
+
const existingReflectionIds = new Set(reflections.map((reflection) => reflection.id));
|
|
71
|
+
const accumulated = new Map<string, Reflection>();
|
|
72
|
+
|
|
73
|
+
const recordReflections: AgentTool<typeof RecordReflectionsSchema> = {
|
|
74
|
+
name: "record_reflections",
|
|
75
|
+
label: "Record reflections",
|
|
76
|
+
description: "Record new durable reflections with supporting observation ids.",
|
|
77
|
+
parameters: RecordReflectionsSchema,
|
|
78
|
+
execute: async (_id, params: RecordReflectionsArgs) => {
|
|
79
|
+
let added = 0;
|
|
80
|
+
let duplicates = 0;
|
|
81
|
+
let rejected = 0;
|
|
82
|
+
for (const proposal of params.reflections) {
|
|
83
|
+
const content = normalizeReflectionContent(proposal.content);
|
|
84
|
+
const supportingObservationIds = normalizeSupportingObservationIds(proposal.supportingObservationIds, allowedObservationIds);
|
|
85
|
+
if (!content || !supportingObservationIds) {
|
|
86
|
+
rejected++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const id = hashId(content);
|
|
90
|
+
if (existingReflectionIds.has(id) || accumulated.has(id)) {
|
|
91
|
+
duplicates++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
accumulated.set(id, {
|
|
95
|
+
id,
|
|
96
|
+
content,
|
|
97
|
+
supportingObservationIds,
|
|
98
|
+
tokenCount: estimateStringTokens(content),
|
|
99
|
+
});
|
|
100
|
+
added++;
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: `Recorded ${added} reflection${added === 1 ? "" : "s"}; ${duplicates} duplicate${duplicates === 1 ? "" : "s"}; ${rejected} rejected. Total this run: ${accumulated.size}.` }],
|
|
104
|
+
details: { added, duplicates, rejected, total: accumulated.size },
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\nCURRENT OBSERVATIONS:\n${joinOrEmpty(observations.map(observationToSummaryLine))}\n\nCrystallize any missing durable facts or patterns into new reflections. If nothing is stable enough, do not call the tool.`;
|
|
110
|
+
const prompts: Message[] = [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }];
|
|
111
|
+
const context: AgentContext = { systemPrompt: REFLECTOR_SYSTEM, messages: [], tools: [recordReflections as AgentTool<any>] };
|
|
112
|
+
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
113
|
+
const thinkingLevel = args.thinkingLevel ?? "low";
|
|
114
|
+
const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
|
|
115
|
+
let turnCount = 0;
|
|
116
|
+
const config: AgentLoopConfig = {
|
|
117
|
+
model,
|
|
118
|
+
apiKey,
|
|
119
|
+
headers,
|
|
120
|
+
maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
|
|
121
|
+
convertToLlm: (msgs) => msgs as Message[],
|
|
122
|
+
toolExecution: "sequential",
|
|
123
|
+
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
124
|
+
...(effectiveMaxTurns !== undefined ? { shouldStopAfterTurn: () => ++turnCount >= effectiveMaxTurns } : {}),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const loop = args.agentLoop ?? agentLoop;
|
|
128
|
+
const stream = loop(prompts, context, config, signal);
|
|
129
|
+
for await (const _event of stream) {
|
|
130
|
+
// Tool execution collects records.
|
|
131
|
+
}
|
|
132
|
+
await stream.result();
|
|
133
|
+
return accumulated.size > 0 ? Array.from(accumulated.values()) : undefined;
|
|
134
|
+
}
|