@oyasmi/pipiclaw 0.6.3 → 0.6.5
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 +12 -4
- package/dist/agent/channel-runner.d.ts +3 -0
- package/dist/agent/channel-runner.js +51 -0
- package/dist/agent/commands.js +3 -1
- package/dist/agent/prompt-builder.js +4 -0
- package/dist/agent/session-events.d.ts +1 -0
- package/dist/agent/session-events.js +13 -1
- package/dist/agent/types.d.ts +2 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/memory/channel-maintenance-queue.d.ts +5 -0
- package/dist/memory/channel-maintenance-queue.js +8 -0
- package/dist/memory/consolidation.d.ts +12 -4
- package/dist/memory/consolidation.js +54 -23
- package/dist/memory/files.js +8 -14
- package/dist/memory/lifecycle.d.ts +8 -14
- package/dist/memory/lifecycle.js +66 -111
- package/dist/memory/maintenance-gates.d.ts +56 -0
- package/dist/memory/maintenance-gates.js +161 -0
- package/dist/memory/maintenance-jobs.d.ts +52 -0
- package/dist/memory/maintenance-jobs.js +310 -0
- package/dist/memory/maintenance-state.d.ts +33 -0
- package/dist/memory/maintenance-state.js +113 -0
- package/dist/memory/post-turn-review.d.ts +32 -0
- package/dist/memory/post-turn-review.js +244 -0
- package/dist/memory/promotion-signals.d.ts +5 -0
- package/dist/memory/promotion-signals.js +34 -0
- package/dist/memory/promotion.d.ts +32 -0
- package/dist/memory/promotion.js +11 -0
- package/dist/memory/recall.d.ts +1 -1
- package/dist/memory/recall.js +33 -1
- package/dist/memory/review-log.d.ts +13 -0
- package/dist/memory/review-log.js +38 -0
- package/dist/memory/scheduler.d.ts +52 -0
- package/dist/memory/scheduler.js +152 -0
- package/dist/memory/session-corpus.d.ts +18 -0
- package/dist/memory/session-corpus.js +257 -0
- package/dist/memory/session-search.d.ts +30 -0
- package/dist/memory/session-search.js +151 -0
- package/dist/runtime/bootstrap.d.ts +5 -0
- package/dist/runtime/bootstrap.js +39 -2
- package/dist/runtime/delivery.js +52 -3
- package/dist/runtime/dingtalk.d.ts +11 -1
- package/dist/runtime/dingtalk.js +40 -3
- package/dist/runtime/events.js +5 -0
- package/dist/settings.d.ts +35 -1
- package/dist/settings.js +55 -1
- package/dist/shared/atomic-file.d.ts +2 -0
- package/dist/shared/atomic-file.js +17 -0
- package/dist/shared/serial-queue.d.ts +4 -0
- package/dist/shared/serial-queue.js +17 -0
- package/dist/tools/config.d.ts +10 -0
- package/dist/tools/config.js +28 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +32 -0
- package/dist/tools/session-search.d.ts +17 -0
- package/dist/tools/session-search.js +56 -0
- package/dist/tools/skill-list.d.ts +17 -0
- package/dist/tools/skill-list.js +86 -0
- package/dist/tools/skill-manage.d.ts +34 -0
- package/dist/tools/skill-manage.js +138 -0
- package/dist/tools/skill-security.d.ts +10 -0
- package/dist/tools/skill-security.js +111 -0
- package/dist/tools/skill-view.d.ts +12 -0
- package/dist/tools/skill-view.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { serializeConversation } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { parseJsonObject } from "../shared/llm-json.js";
|
|
3
|
+
import { clipText } from "../shared/text-utils.js";
|
|
4
|
+
import { buildStandardMessages, isRecord } from "../shared/type-guards.js";
|
|
5
|
+
import { manageWorkspaceSkill } from "../tools/skill-manage.js";
|
|
6
|
+
import { appendChannelMemoryUpdate, readChannelHistory, readChannelMemory, readChannelSession } from "./files.js";
|
|
7
|
+
import { shouldAutoWriteMemory, shouldAutoWriteSkill, } from "./promotion.js";
|
|
8
|
+
import { appendMemoryReviewLog } from "./review-log.js";
|
|
9
|
+
import { runRetriedSidecarTask } from "./sidecar-worker.js";
|
|
10
|
+
const POST_TURN_REVIEW_SYSTEM_PROMPT = `You are Pipiclaw's post-turn memory reviewer.
|
|
11
|
+
|
|
12
|
+
Return strict JSON only:
|
|
13
|
+
{
|
|
14
|
+
"memoryCandidates": [
|
|
15
|
+
{
|
|
16
|
+
"target": "channel-memory",
|
|
17
|
+
"content": "standalone durable memory bullet without '-'",
|
|
18
|
+
"confidence": 0.0,
|
|
19
|
+
"necessity": "low|medium|high",
|
|
20
|
+
"reason": "why it should or should not be stored"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"skillCandidates": [
|
|
24
|
+
{
|
|
25
|
+
"action": "create|patch|write_file",
|
|
26
|
+
"name": "skill-name",
|
|
27
|
+
"content": "full SKILL.md or supporting file content",
|
|
28
|
+
"filePath": "optional supporting file path",
|
|
29
|
+
"find": "exact patch find text",
|
|
30
|
+
"replace": "exact patch replacement text",
|
|
31
|
+
"confidence": 0.0,
|
|
32
|
+
"necessity": "low|medium|high",
|
|
33
|
+
"reason": "why this procedural memory matters"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"discarded": [{"content": "string", "reason": "string"}]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Rules:
|
|
40
|
+
- Channel MEMORY.md is only for durable facts, durable decisions, user/team preferences, stable constraints, and medium-horizon open loops.
|
|
41
|
+
- Do not promote current step-by-step execution state, short-lived debugging observations, completed worklog, or acknowledgement chatter.
|
|
42
|
+
- Workspace skills are procedural memory: reusable workflows, checklists, playbooks, templates, or scripts.
|
|
43
|
+
- Propose skill writes only when the workflow is clearly reusable across future tasks.
|
|
44
|
+
- Use action=create only for new self-contained skills with YAML frontmatter and a non-empty body.
|
|
45
|
+
- Skill names must be lowercase kebab-case.
|
|
46
|
+
- Be conservative. Empty arrays are correct when nothing should be stored.`;
|
|
47
|
+
function normalizeConfidence(value) {
|
|
48
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
|
49
|
+
}
|
|
50
|
+
function normalizeNecessity(value) {
|
|
51
|
+
return value === "high" || value === "medium" || value === "low" ? value : "low";
|
|
52
|
+
}
|
|
53
|
+
function normalizeMemoryCandidate(value) {
|
|
54
|
+
if (!isRecord(value)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (value.target !== "channel-memory") {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const target = "channel-memory";
|
|
61
|
+
const content = typeof value.content === "string" ? value.content.trim() : "";
|
|
62
|
+
if (!content) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
target,
|
|
67
|
+
content,
|
|
68
|
+
confidence: normalizeConfidence(value.confidence),
|
|
69
|
+
necessity: normalizeNecessity(value.necessity),
|
|
70
|
+
reason: typeof value.reason === "string" ? value.reason.trim() : "",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function normalizeSkillCandidate(value) {
|
|
74
|
+
if (!isRecord(value)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const action = value.action;
|
|
78
|
+
if (action !== "create" && action !== "patch" && action !== "write_file") {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const name = typeof value.name === "string" ? value.name.trim() : "";
|
|
82
|
+
if (!name) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
action,
|
|
87
|
+
name,
|
|
88
|
+
content: typeof value.content === "string" ? value.content : undefined,
|
|
89
|
+
filePath: typeof value.filePath === "string" ? value.filePath : undefined,
|
|
90
|
+
find: typeof value.find === "string" ? value.find : undefined,
|
|
91
|
+
replace: typeof value.replace === "string" ? value.replace : undefined,
|
|
92
|
+
confidence: normalizeConfidence(value.confidence),
|
|
93
|
+
necessity: normalizeNecessity(value.necessity),
|
|
94
|
+
reason: typeof value.reason === "string" ? value.reason.trim() : "",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function parsePostTurnReviewResult(value) {
|
|
98
|
+
const record = isRecord(value) ? value : {};
|
|
99
|
+
const memoryCandidates = Array.isArray(record.memoryCandidates)
|
|
100
|
+
? record.memoryCandidates.map(normalizeMemoryCandidate).filter((item) => !!item)
|
|
101
|
+
: [];
|
|
102
|
+
const skillCandidates = Array.isArray(record.skillCandidates)
|
|
103
|
+
? record.skillCandidates.map(normalizeSkillCandidate).filter((item) => !!item)
|
|
104
|
+
: [];
|
|
105
|
+
const discarded = Array.isArray(record.discarded)
|
|
106
|
+
? record.discarded
|
|
107
|
+
.filter(isRecord)
|
|
108
|
+
.map((item) => ({
|
|
109
|
+
content: typeof item.content === "string" ? item.content : "",
|
|
110
|
+
reason: typeof item.reason === "string" ? item.reason : "",
|
|
111
|
+
}))
|
|
112
|
+
.filter((item) => item.content.trim() || item.reason.trim())
|
|
113
|
+
: [];
|
|
114
|
+
return { memoryCandidates, skillCandidates, discarded };
|
|
115
|
+
}
|
|
116
|
+
async function runPostTurnReviewWorker(options) {
|
|
117
|
+
const [currentSession, currentMemory, currentHistory] = await Promise.all([
|
|
118
|
+
readChannelSession(options.channelDir),
|
|
119
|
+
readChannelMemory(options.channelDir),
|
|
120
|
+
readChannelHistory(options.channelDir),
|
|
121
|
+
]);
|
|
122
|
+
const transcript = clipText(serializeConversation(buildStandardMessages(options.messages)), 22_000, {
|
|
123
|
+
headRatio: 0.35,
|
|
124
|
+
});
|
|
125
|
+
const skills = options.loadedSkills
|
|
126
|
+
.map((skill) => `- ${skill.name}${skill.description ? `: ${skill.description}` : ""}`)
|
|
127
|
+
.join("\n");
|
|
128
|
+
const prompt = `Current SESSION.md:
|
|
129
|
+
${clipText(currentSession, 6_000, { headRatio: 0.5 }) || "(empty)"}
|
|
130
|
+
|
|
131
|
+
Current channel MEMORY.md:
|
|
132
|
+
${clipText(currentMemory, 6_000, { headRatio: 0.5 }) || "(empty)"}
|
|
133
|
+
|
|
134
|
+
Current channel HISTORY.md:
|
|
135
|
+
${clipText(currentHistory, 2_000, { headRatio: 0.3 }) || "(empty)"}
|
|
136
|
+
|
|
137
|
+
Loaded workspace skills:
|
|
138
|
+
${skills || "(none)"}
|
|
139
|
+
|
|
140
|
+
Recent transcript:
|
|
141
|
+
${transcript || "(empty)"}`;
|
|
142
|
+
const result = await runRetriedSidecarTask({
|
|
143
|
+
name: "memory-post-turn-review",
|
|
144
|
+
model: options.model,
|
|
145
|
+
resolveApiKey: options.resolveApiKey,
|
|
146
|
+
systemPrompt: POST_TURN_REVIEW_SYSTEM_PROMPT,
|
|
147
|
+
prompt,
|
|
148
|
+
timeoutMs: options.timeoutMs,
|
|
149
|
+
parse: (text) => parsePostTurnReviewResult(parseJsonObject(text)),
|
|
150
|
+
});
|
|
151
|
+
return result.output;
|
|
152
|
+
}
|
|
153
|
+
async function applyMemoryCandidate(options, candidate, result, timestamp) {
|
|
154
|
+
if (!options.autoWriteChannelMemory || !shouldAutoWriteMemory(candidate, options.minMemoryAutoWriteConfidence)) {
|
|
155
|
+
result.suggestions.push({ type: "memory", candidate });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await appendChannelMemoryUpdate(options.channelDir, {
|
|
159
|
+
timestamp,
|
|
160
|
+
entries: [candidate.content],
|
|
161
|
+
});
|
|
162
|
+
const action = { target: "MEMORY.md", action: "append", content: candidate.content, reason: candidate.reason };
|
|
163
|
+
result.actions.push(action);
|
|
164
|
+
result.notices.push("已沉淀:更新 channel memory。");
|
|
165
|
+
}
|
|
166
|
+
async function applySkillCandidate(options, candidate, result) {
|
|
167
|
+
if (!options.autoWriteWorkspaceSkills || !shouldAutoWriteSkill(candidate, options.minSkillAutoWriteConfidence)) {
|
|
168
|
+
result.suggestions.push({ type: "skill", candidate });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const managed = await manageWorkspaceSkill({ workspaceDir: options.workspaceDir, workspacePath: options.workspacePath }, {
|
|
173
|
+
action: candidate.action,
|
|
174
|
+
name: candidate.name,
|
|
175
|
+
content: candidate.content,
|
|
176
|
+
filePath: candidate.filePath,
|
|
177
|
+
find: candidate.find,
|
|
178
|
+
replace: candidate.replace,
|
|
179
|
+
});
|
|
180
|
+
result.actions.push({ target: "workspace-skill", ...managed, reason: candidate.reason });
|
|
181
|
+
result.notices.push(managed.notice);
|
|
182
|
+
if (managed.requiresResourceRefresh && options.refreshWorkspaceResources) {
|
|
183
|
+
await options.refreshWorkspaceResources();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
188
|
+
result.skipped.push({ type: "skill", candidate, reason: message });
|
|
189
|
+
result.suggestions.push({ type: "skill", candidate, blockedReason: message });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export async function applyPostTurnReviewResult(options, review) {
|
|
193
|
+
const timestamp = new Date().toISOString();
|
|
194
|
+
const result = {
|
|
195
|
+
actions: [],
|
|
196
|
+
suggestions: [],
|
|
197
|
+
skipped: [],
|
|
198
|
+
notices: [],
|
|
199
|
+
};
|
|
200
|
+
for (const candidate of review.memoryCandidates) {
|
|
201
|
+
await applyMemoryCandidate(options, candidate, result, timestamp);
|
|
202
|
+
}
|
|
203
|
+
for (const candidate of review.skillCandidates) {
|
|
204
|
+
await applySkillCandidate(options, candidate, result);
|
|
205
|
+
}
|
|
206
|
+
for (const discarded of review.discarded) {
|
|
207
|
+
result.skipped.push({ type: "discarded", ...discarded });
|
|
208
|
+
}
|
|
209
|
+
await appendMemoryReviewLog(options.channelDir, {
|
|
210
|
+
timestamp,
|
|
211
|
+
channelId: options.channelId,
|
|
212
|
+
reason: "post-turn",
|
|
213
|
+
candidates: [...review.memoryCandidates, ...review.skillCandidates],
|
|
214
|
+
actions: result.actions,
|
|
215
|
+
suggestions: result.suggestions,
|
|
216
|
+
skipped: result.skipped,
|
|
217
|
+
});
|
|
218
|
+
for (const notice of Array.from(new Set(result.notices))) {
|
|
219
|
+
try {
|
|
220
|
+
await options.emitNotice?.(notice);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* best effort */
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
export async function runPostTurnReview(options) {
|
|
229
|
+
try {
|
|
230
|
+
const review = await runPostTurnReviewWorker(options);
|
|
231
|
+
return await applyPostTurnReviewResult(options, review);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
235
|
+
await appendMemoryReviewLog(options.channelDir, {
|
|
236
|
+
timestamp: new Date().toISOString(),
|
|
237
|
+
channelId: options.channelId,
|
|
238
|
+
reason: "post-turn",
|
|
239
|
+
error: message,
|
|
240
|
+
skipped: [{ target: "post-turn-review", reason: "failed" }],
|
|
241
|
+
});
|
|
242
|
+
return { actions: [], suggestions: [], skipped: [{ reason: message }], notices: [] };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const PROMOTION_SIGNAL_PATTERNS = [
|
|
2
|
+
/以后/,
|
|
3
|
+
/默认/,
|
|
4
|
+
/记住/,
|
|
5
|
+
/偏好/,
|
|
6
|
+
/决定/,
|
|
7
|
+
/确认/,
|
|
8
|
+
/采用/,
|
|
9
|
+
/不再/,
|
|
10
|
+
/流程/,
|
|
11
|
+
/步骤/,
|
|
12
|
+
/规范/,
|
|
13
|
+
/checklist/i,
|
|
14
|
+
/每次/,
|
|
15
|
+
/后续/,
|
|
16
|
+
/待办/,
|
|
17
|
+
/需要跟进/,
|
|
18
|
+
/\bprefer(?:s|red|ence)?\b/i,
|
|
19
|
+
/\bdefault\b/i,
|
|
20
|
+
/\bremember\b/i,
|
|
21
|
+
/\bdecision\b/i,
|
|
22
|
+
/\badopt\b/i,
|
|
23
|
+
/\bworkflow\b/i,
|
|
24
|
+
/\bprocess\b/i,
|
|
25
|
+
/\bnext steps?\b/i,
|
|
26
|
+
/\bfollow[- ]?up\b/i,
|
|
27
|
+
];
|
|
28
|
+
export function scanPromotionSignals(text) {
|
|
29
|
+
const matchedSignals = PROMOTION_SIGNAL_PATTERNS.filter((pattern) => pattern.test(text)).map((pattern) => pattern.toString());
|
|
30
|
+
return {
|
|
31
|
+
hasSignal: matchedSignals.length > 0,
|
|
32
|
+
matchedSignals,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type MemoryPromotionTarget = "channel-memory";
|
|
2
|
+
export type SkillPromotionAction = "create" | "patch" | "write_file";
|
|
3
|
+
export interface MemoryPromotionCandidate {
|
|
4
|
+
target: MemoryPromotionTarget;
|
|
5
|
+
content: string;
|
|
6
|
+
confidence: number;
|
|
7
|
+
reason: string;
|
|
8
|
+
necessity: "low" | "medium" | "high";
|
|
9
|
+
}
|
|
10
|
+
export interface SkillPromotionCandidate {
|
|
11
|
+
action: SkillPromotionAction;
|
|
12
|
+
name: string;
|
|
13
|
+
content?: string;
|
|
14
|
+
filePath?: string;
|
|
15
|
+
find?: string;
|
|
16
|
+
replace?: string;
|
|
17
|
+
confidence: number;
|
|
18
|
+
necessity: "low" | "medium" | "high";
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PostTurnReviewResult {
|
|
22
|
+
memoryCandidates: MemoryPromotionCandidate[];
|
|
23
|
+
skillCandidates: SkillPromotionCandidate[];
|
|
24
|
+
discarded: Array<{
|
|
25
|
+
content: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
export declare const DEFAULT_MEMORY_AUTO_WRITE_CONFIDENCE = 0.85;
|
|
30
|
+
export declare const DEFAULT_SKILL_AUTO_WRITE_CONFIDENCE = 0.9;
|
|
31
|
+
export declare function shouldAutoWriteMemory(candidate: MemoryPromotionCandidate, threshold?: number): boolean;
|
|
32
|
+
export declare function shouldAutoWriteSkill(candidate: SkillPromotionCandidate, threshold?: number): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const DEFAULT_MEMORY_AUTO_WRITE_CONFIDENCE = 0.85;
|
|
2
|
+
export const DEFAULT_SKILL_AUTO_WRITE_CONFIDENCE = 0.9;
|
|
3
|
+
function isHighNecessity(value) {
|
|
4
|
+
return value === "high";
|
|
5
|
+
}
|
|
6
|
+
export function shouldAutoWriteMemory(candidate, threshold = DEFAULT_MEMORY_AUTO_WRITE_CONFIDENCE) {
|
|
7
|
+
return (candidate.confidence >= threshold && isHighNecessity(candidate.necessity) && candidate.content.trim().length > 0);
|
|
8
|
+
}
|
|
9
|
+
export function shouldAutoWriteSkill(candidate, threshold = DEFAULT_SKILL_AUTO_WRITE_CONFIDENCE) {
|
|
10
|
+
return candidate.confidence >= threshold && candidate.necessity === "high";
|
|
11
|
+
}
|
package/dist/memory/recall.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface RecallRequest {
|
|
|
8
8
|
maxCandidates: number;
|
|
9
9
|
maxInjected: number;
|
|
10
10
|
maxChars: number;
|
|
11
|
-
rerankWithModel: boolean;
|
|
11
|
+
rerankWithModel: boolean | "auto";
|
|
12
12
|
autoRerank?: boolean;
|
|
13
13
|
model: Model<Api>;
|
|
14
14
|
resolveApiKey: (model: Model<Api>) => Promise<string>;
|
package/dist/memory/recall.js
CHANGED
|
@@ -19,6 +19,8 @@ const TOKEN_PART_REGEX = /[\p{Script=Han}]+|[\p{L}\p{N}_./-]+/gu;
|
|
|
19
19
|
const ASCII_SPLIT_REGEX = /[._/-]+/g;
|
|
20
20
|
const MEMORY_RECALL_RERANK_TIMEOUT_MS = 8_000;
|
|
21
21
|
const RERANK_CONTENT_CLIP = 800;
|
|
22
|
+
const HIGH_CONFIDENCE_SCORE = 36;
|
|
23
|
+
const CLOSE_SCORE_DELTA = 8;
|
|
22
24
|
const MAX_HAN_WORD_LENGTH = Array.from(COMMON_CHINESE_WORDS).reduce((max, word) => Math.max(max, word.length), 2);
|
|
23
25
|
const LATIN_STOP_WORDS = new Set([
|
|
24
26
|
"a",
|
|
@@ -383,7 +385,7 @@ function seedIntentCandidates(request, candidates, existing, intents, queryToken
|
|
|
383
385
|
return seeded;
|
|
384
386
|
}
|
|
385
387
|
async function rerankCandidates(request, candidates) {
|
|
386
|
-
if ((
|
|
388
|
+
if (!shouldUseModelRerank(request, candidates)) {
|
|
387
389
|
return candidates;
|
|
388
390
|
}
|
|
389
391
|
const renderedCandidates = candidates
|
|
@@ -429,6 +431,36 @@ async function rerankCandidates(request, candidates) {
|
|
|
429
431
|
return candidates;
|
|
430
432
|
}
|
|
431
433
|
}
|
|
434
|
+
function hasMemorySensitiveQueryIntent(query) {
|
|
435
|
+
if (HAN_REGEX.test(query)) {
|
|
436
|
+
return /(之前|上次|记得|记住|偏好|决定|历史|纠正|不要再|以后|默认)/.test(query);
|
|
437
|
+
}
|
|
438
|
+
return /\b(previous|previously|last time|remember|preference|decision|history|correction|again|default)\b/i.test(query);
|
|
439
|
+
}
|
|
440
|
+
function shouldUseModelRerank(request, candidates) {
|
|
441
|
+
if (candidates.length <= request.maxInjected) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if (request.rerankWithModel === true) {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (request.rerankWithModel === false && !request.autoRerank) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
const top = candidates[0];
|
|
451
|
+
const next = candidates[1];
|
|
452
|
+
if (!top || !next) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
const highLocalConfidence = top.score >= HIGH_CONFIDENCE_SCORE && top.score - next.score >= CLOSE_SCORE_DELTA;
|
|
456
|
+
if (highLocalConfidence) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
if (!request.autoRerank && !hasMemorySensitiveQueryIntent(request.query)) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
432
464
|
function renderRecallResult(items, maxChars) {
|
|
433
465
|
if (items.length === 0) {
|
|
434
466
|
return "";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type MemoryReviewReason = "idle" | "compaction" | "new-session" | "shutdown" | "post-turn" | "session-refresh-job" | "durable-consolidation-job" | "growth-review-job" | "structural-maintenance-job";
|
|
2
|
+
export interface MemoryReviewLogEntry {
|
|
3
|
+
timestamp: string;
|
|
4
|
+
channelId: string;
|
|
5
|
+
reason: MemoryReviewReason;
|
|
6
|
+
candidates?: unknown[];
|
|
7
|
+
actions?: unknown[];
|
|
8
|
+
suggestions?: unknown[];
|
|
9
|
+
skipped?: unknown[];
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function getMemoryReviewLogPath(channelDir: string): string;
|
|
13
|
+
export declare function appendMemoryReviewLog(channelDir: string, entry: MemoryReviewLogEntry): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { writeFileAtomically } from "../shared/atomic-file.js";
|
|
4
|
+
import { createSerialQueue } from "../shared/serial-queue.js";
|
|
5
|
+
const REVIEW_LOG_MAX_BYTES = 1_024 * 1_024; // 1 MB
|
|
6
|
+
const writeQueue = createSerialQueue();
|
|
7
|
+
export function getMemoryReviewLogPath(channelDir) {
|
|
8
|
+
return join(channelDir, "memory-review.jsonl");
|
|
9
|
+
}
|
|
10
|
+
async function rotateIfNeeded(path, incomingBytes) {
|
|
11
|
+
try {
|
|
12
|
+
const stats = await stat(path);
|
|
13
|
+
if (stats.size + incomingBytes < REVIEW_LOG_MAX_BYTES) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const rotated = `${path}.1`;
|
|
17
|
+
const current = await readFile(path, "utf-8");
|
|
18
|
+
const lines = current.split("\n").filter(Boolean);
|
|
19
|
+
const keepLines = lines.slice(-Math.floor(lines.length / 2));
|
|
20
|
+
await writeFileAtomically(rotated, keepLines.length > 0 ? `${keepLines.join("\n")}\n` : "");
|
|
21
|
+
await writeFileAtomically(path, "");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// Rotation failure is non-fatal
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function appendMemoryReviewLog(channelDir, entry) {
|
|
31
|
+
const path = getMemoryReviewLogPath(channelDir);
|
|
32
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
33
|
+
await writeQueue.run(path, async () => {
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
await rotateIfNeeded(path, Buffer.byteLength(line, "utf-8"));
|
|
36
|
+
await appendFile(path, line, "utf-8");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
3
|
+
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { PipiclawMemoryGrowthSettings, PipiclawMemoryMaintenanceSettings, PipiclawSessionMemorySettings } from "../settings.js";
|
|
5
|
+
export interface MemoryMaintenanceRuntimeContext {
|
|
6
|
+
channelId: string;
|
|
7
|
+
channelDir: string;
|
|
8
|
+
workspaceDir: string;
|
|
9
|
+
workspacePath: string;
|
|
10
|
+
messages: AgentMessage[];
|
|
11
|
+
sessionEntries: SessionEntry[];
|
|
12
|
+
model: Model<Api>;
|
|
13
|
+
resolveApiKey: (model: Model<Api>) => Promise<string>;
|
|
14
|
+
settings: {
|
|
15
|
+
sessionMemory: PipiclawSessionMemorySettings;
|
|
16
|
+
memoryGrowth: PipiclawMemoryGrowthSettings;
|
|
17
|
+
memoryMaintenance: PipiclawMemoryMaintenanceSettings;
|
|
18
|
+
};
|
|
19
|
+
loadedSkills: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
}>;
|
|
23
|
+
refreshWorkspaceResources?: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface MemoryMaintenanceSchedulerOptions {
|
|
26
|
+
appHomeDir: string;
|
|
27
|
+
workspaceDir: string;
|
|
28
|
+
getKnownChannelIds?: () => Iterable<string>;
|
|
29
|
+
getRuntimeContext: (channelId: string) => Promise<MemoryMaintenanceRuntimeContext | null>;
|
|
30
|
+
isChannelActive: (channelId: string) => boolean;
|
|
31
|
+
getSettings: () => {
|
|
32
|
+
memoryMaintenance: PipiclawMemoryMaintenanceSettings;
|
|
33
|
+
};
|
|
34
|
+
emitNotice?: (channelId: string, notice: string) => Promise<void>;
|
|
35
|
+
intervalMs?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function discoverMemoryMaintenanceChannels(input: {
|
|
38
|
+
appHomeDir: string;
|
|
39
|
+
workspaceDir: string;
|
|
40
|
+
knownChannelIds?: Iterable<string>;
|
|
41
|
+
}): Promise<string[]>;
|
|
42
|
+
export declare class MemoryMaintenanceScheduler {
|
|
43
|
+
private readonly options;
|
|
44
|
+
private timer;
|
|
45
|
+
private running;
|
|
46
|
+
private nextChannelIndex;
|
|
47
|
+
constructor(options: MemoryMaintenanceSchedulerOptions);
|
|
48
|
+
start(): void;
|
|
49
|
+
stop(): void;
|
|
50
|
+
runOnce(now?: Date): Promise<void>;
|
|
51
|
+
private runChannelOnce;
|
|
52
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import * as log from "../log.js";
|
|
4
|
+
import { runDurableConsolidationJob, runGrowthReviewJob, runSessionRefreshJob, runStructuralMaintenanceJob, } from "./maintenance-jobs.js";
|
|
5
|
+
import { getMemoryMaintenanceStateDir } from "./maintenance-state.js";
|
|
6
|
+
const DEFAULT_TICK_INTERVAL_MS = 60_000;
|
|
7
|
+
const CHANNEL_ID_PATTERN = /^(dm|group)_[A-Za-z0-9._:-]+$/;
|
|
8
|
+
function isChannelId(value) {
|
|
9
|
+
return CHANNEL_ID_PATTERN.test(value);
|
|
10
|
+
}
|
|
11
|
+
async function listWorkspaceChannels(workspaceDir) {
|
|
12
|
+
try {
|
|
13
|
+
const entries = await readdir(workspaceDir, { withFileTypes: true });
|
|
14
|
+
return entries.filter((entry) => entry.isDirectory() && isChannelId(entry.name)).map((entry) => entry.name);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function listStateChannels(appHomeDir) {
|
|
21
|
+
try {
|
|
22
|
+
const entries = await readdir(getMemoryMaintenanceStateDir(appHomeDir), { withFileTypes: true });
|
|
23
|
+
return entries
|
|
24
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
25
|
+
.map((entry) => basename(entry.name, ".json"))
|
|
26
|
+
.filter(isChannelId);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function discoverMemoryMaintenanceChannels(input) {
|
|
33
|
+
const channels = new Set();
|
|
34
|
+
for (const channelId of input.knownChannelIds ?? []) {
|
|
35
|
+
if (isChannelId(channelId)) {
|
|
36
|
+
channels.add(channelId);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const channelId of await listWorkspaceChannels(input.workspaceDir)) {
|
|
40
|
+
channels.add(channelId);
|
|
41
|
+
}
|
|
42
|
+
for (const channelId of await listStateChannels(input.appHomeDir)) {
|
|
43
|
+
channels.add(channelId);
|
|
44
|
+
}
|
|
45
|
+
return Array.from(channels).sort();
|
|
46
|
+
}
|
|
47
|
+
function normalizeMaxConcurrentChannels(value) {
|
|
48
|
+
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 1;
|
|
49
|
+
}
|
|
50
|
+
export class MemoryMaintenanceScheduler {
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.options = options;
|
|
53
|
+
this.timer = null;
|
|
54
|
+
this.running = false;
|
|
55
|
+
this.nextChannelIndex = 0;
|
|
56
|
+
}
|
|
57
|
+
start() {
|
|
58
|
+
if (this.timer || !this.options.getSettings().memoryMaintenance.enabled) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.timer = setInterval(() => {
|
|
62
|
+
void this.runOnce().catch((error) => {
|
|
63
|
+
log.logWarning("Memory maintenance scheduler tick failed", error instanceof Error ? error.message : String(error));
|
|
64
|
+
});
|
|
65
|
+
}, this.options.intervalMs ?? DEFAULT_TICK_INTERVAL_MS);
|
|
66
|
+
this.timer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
stop() {
|
|
69
|
+
if (!this.timer) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
clearInterval(this.timer);
|
|
73
|
+
this.timer = null;
|
|
74
|
+
}
|
|
75
|
+
async runOnce(now = new Date()) {
|
|
76
|
+
const settings = this.options.getSettings().memoryMaintenance;
|
|
77
|
+
if (!settings.enabled || this.running) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.running = true;
|
|
81
|
+
try {
|
|
82
|
+
const channelIds = await discoverMemoryMaintenanceChannels({
|
|
83
|
+
appHomeDir: this.options.appHomeDir,
|
|
84
|
+
workspaceDir: this.options.workspaceDir,
|
|
85
|
+
knownChannelIds: this.options.getKnownChannelIds?.(),
|
|
86
|
+
});
|
|
87
|
+
const maxConcurrent = normalizeMaxConcurrentChannels(settings.maxConcurrentChannels);
|
|
88
|
+
if (channelIds.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const selected = [];
|
|
92
|
+
let scanned = 0;
|
|
93
|
+
let index = this.nextChannelIndex % channelIds.length;
|
|
94
|
+
while (scanned < channelIds.length && selected.length < maxConcurrent) {
|
|
95
|
+
const channelId = channelIds[index];
|
|
96
|
+
if (channelId && !this.options.isChannelActive(channelId)) {
|
|
97
|
+
selected.push(channelId);
|
|
98
|
+
}
|
|
99
|
+
index = (index + 1) % channelIds.length;
|
|
100
|
+
scanned++;
|
|
101
|
+
}
|
|
102
|
+
this.nextChannelIndex = index;
|
|
103
|
+
await Promise.all(selected.map((channelId) => this.runChannelOnce(channelId, now)));
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
this.running = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async runChannelOnce(channelId, now) {
|
|
110
|
+
if (this.options.isChannelActive(channelId)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const context = await this.options.getRuntimeContext(channelId);
|
|
114
|
+
if (!context) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const common = {
|
|
118
|
+
appHomeDir: this.options.appHomeDir,
|
|
119
|
+
channelId,
|
|
120
|
+
channelDir: context.channelDir,
|
|
121
|
+
channelActive: this.options.isChannelActive(channelId),
|
|
122
|
+
now,
|
|
123
|
+
settings: context.settings,
|
|
124
|
+
model: context.model,
|
|
125
|
+
resolveApiKey: context.resolveApiKey,
|
|
126
|
+
messages: context.messages,
|
|
127
|
+
sessionEntries: context.sessionEntries,
|
|
128
|
+
};
|
|
129
|
+
const session = await runSessionRefreshJob(common);
|
|
130
|
+
if (session.ran) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const durable = await runDurableConsolidationJob(common);
|
|
134
|
+
if (durable.ran) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const growth = await runGrowthReviewJob({
|
|
138
|
+
...common,
|
|
139
|
+
workspaceDir: context.workspaceDir,
|
|
140
|
+
workspacePath: context.workspacePath,
|
|
141
|
+
loadedSkills: context.loadedSkills,
|
|
142
|
+
emitNotice: this.options.emitNotice
|
|
143
|
+
? async (notice) => this.options.emitNotice?.(channelId, notice)
|
|
144
|
+
: undefined,
|
|
145
|
+
refreshWorkspaceResources: context.refreshWorkspaceResources,
|
|
146
|
+
});
|
|
147
|
+
if (growth.ran) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await runStructuralMaintenanceJob(common);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type SessionSearchSource = "context" | "session" | "log";
|
|
2
|
+
export type SessionSearchRole = "user" | "assistant" | "tool" | "system" | "unknown";
|
|
3
|
+
export interface SessionSearchDocument {
|
|
4
|
+
id: string;
|
|
5
|
+
source: SessionSearchSource;
|
|
6
|
+
path: string;
|
|
7
|
+
timestamp?: string;
|
|
8
|
+
role: SessionSearchRole;
|
|
9
|
+
text: string;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface BuildSessionCorpusOptions {
|
|
13
|
+
channelDir: string;
|
|
14
|
+
maxFiles: number;
|
|
15
|
+
maxCharsPerDocument?: number;
|
|
16
|
+
maxDocumentsTotal?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function buildSessionCorpus(options: BuildSessionCorpusOptions): Promise<SessionSearchDocument[]>;
|