pi-chalin 0.1.0 → 0.2.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 +47 -4
- package/package.json +1 -1
- package/src/autoroute.ts +2 -2
- package/src/child-tools.ts +5 -4
- package/src/commands.ts +74 -7
- package/src/config.ts +58 -0
- package/src/index.ts +2 -0
- package/src/kernel.ts +7 -11
- package/src/memory-provider.ts +701 -0
- package/src/memory.ts +18 -1
- package/src/runner.ts +3 -2
- package/src/schemas.ts +1 -0
- package/src/tools.ts +7 -6
- package/src/ui.ts +355 -2
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { DEFAULT_CONFIG, loadEffectiveConfig, type ChalinConfig, type MemoryProvider } from "./config.ts";
|
|
5
|
+
import type { ChalinPathsOptions } from "./paths.ts";
|
|
6
|
+
import type { MemoryAuditEvent, MemoryCandidate, MemoryRecord } from "./schemas.ts";
|
|
7
|
+
import {
|
|
8
|
+
MemoryStore,
|
|
9
|
+
prepareMemoryRecords,
|
|
10
|
+
type MemoryContextBundle,
|
|
11
|
+
type MemoryContextRequest,
|
|
12
|
+
type MemoryRevisionInput,
|
|
13
|
+
type MemorySearchResult,
|
|
14
|
+
type MemoryStoreLike,
|
|
15
|
+
} from "./memory.ts";
|
|
16
|
+
|
|
17
|
+
export interface MemoryBackendStatus {
|
|
18
|
+
configuredProvider: MemoryProvider;
|
|
19
|
+
activeProvider: "engram" | "pi-chalin" | "unavailable";
|
|
20
|
+
engramAvailable: boolean;
|
|
21
|
+
summary: string;
|
|
22
|
+
detail?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface EngramStoreOptions extends ChalinPathsOptions {
|
|
26
|
+
config: ChalinConfig["memory"]["engram"];
|
|
27
|
+
fallback?: MemoryStoreLike;
|
|
28
|
+
forceStart?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface EngramObservation {
|
|
32
|
+
id?: number | string;
|
|
33
|
+
session_id?: string;
|
|
34
|
+
type?: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
content?: string;
|
|
37
|
+
project?: string | null;
|
|
38
|
+
scope?: string;
|
|
39
|
+
topic_key?: string | null;
|
|
40
|
+
revision_count?: number;
|
|
41
|
+
duplicate_count?: number;
|
|
42
|
+
last_seen_at?: string | null;
|
|
43
|
+
created_at?: string;
|
|
44
|
+
updated_at?: string;
|
|
45
|
+
rank?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EngramProjectResponse {
|
|
49
|
+
project?: string;
|
|
50
|
+
warning?: string;
|
|
51
|
+
error_hint?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface EngramSyncStatus {
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
phase?: string;
|
|
57
|
+
reason_code?: string;
|
|
58
|
+
reason_message?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cloudSyncAttempts = new Map<string, { at: number; promise?: Promise<void> }>();
|
|
62
|
+
|
|
63
|
+
class AutoMemoryStore implements MemoryStoreLike {
|
|
64
|
+
constructor(private readonly engram: EngramMemoryStore, private readonly local: MemoryStoreLike) {}
|
|
65
|
+
|
|
66
|
+
private async target(): Promise<MemoryStoreLike> {
|
|
67
|
+
return await this.engram.isAvailable(false) ? this.engram : this.local;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async submitCandidates(candidates: MemoryCandidate[]): Promise<MemoryRecord[]> {
|
|
71
|
+
return (await this.target()).submitCandidates(candidates);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async list(status?: MemoryRecord["status"]): Promise<MemoryRecord[]> {
|
|
75
|
+
return (await this.target()).list(status);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async pendingCount(): Promise<number> {
|
|
79
|
+
return (await this.target()).pendingCount();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async approve(id: string): Promise<MemoryRecord | undefined> {
|
|
83
|
+
return (await this.target()).approve(id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async reject(id: string): Promise<MemoryRecord | undefined> {
|
|
87
|
+
return (await this.target()).reject(id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async delete(id: string): Promise<boolean> {
|
|
91
|
+
return (await this.target()).delete(id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async search(query: string, limit?: number): Promise<MemorySearchResult[]> {
|
|
95
|
+
return (await this.target()).search(query, limit);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async retrieve(request: MemoryContextRequest): Promise<MemoryContextBundle> {
|
|
99
|
+
return (await this.target()).retrieve(request);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async revise(id: string, input: MemoryRevisionInput): Promise<MemoryRecord | undefined> {
|
|
103
|
+
return (await this.target()).revise(id, input);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async events(recordId?: string): Promise<MemoryAuditEvent[]> {
|
|
107
|
+
return (await this.target()).events(recordId);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class EngramMemoryStore implements MemoryStoreLike {
|
|
112
|
+
private readonly cwd: string;
|
|
113
|
+
private readonly baseUrl: string;
|
|
114
|
+
private readonly command: string;
|
|
115
|
+
private readonly autoStart: boolean;
|
|
116
|
+
private readonly autoSync: boolean;
|
|
117
|
+
private readonly syncThrottleMs: number;
|
|
118
|
+
private readonly timeoutMs: number;
|
|
119
|
+
private readonly configuredProject?: string;
|
|
120
|
+
private readonly fallback?: MemoryStoreLike;
|
|
121
|
+
private readonly forceStart: boolean;
|
|
122
|
+
private startAttempted = false;
|
|
123
|
+
private projectCache?: string;
|
|
124
|
+
private readonly knownSessions = new Set<string>();
|
|
125
|
+
|
|
126
|
+
constructor(options: EngramStoreOptions) {
|
|
127
|
+
this.cwd = path.resolve(options.cwd);
|
|
128
|
+
this.baseUrl = resolveEngramBaseUrl(options.config);
|
|
129
|
+
this.command = process.env.ENGRAM_BIN?.trim() || options.config.command;
|
|
130
|
+
this.autoStart = options.config.autoStart;
|
|
131
|
+
this.autoSync = options.config.autoSync;
|
|
132
|
+
this.syncThrottleMs = options.config.syncThrottleMs;
|
|
133
|
+
this.timeoutMs = options.config.timeoutMs;
|
|
134
|
+
this.configuredProject = options.config.project?.trim() || undefined;
|
|
135
|
+
this.fallback = options.fallback;
|
|
136
|
+
this.forceStart = Boolean(options.forceStart);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async isAvailable(allowStart = this.autoStart || this.forceStart): Promise<boolean> {
|
|
140
|
+
if (await this.health()) return true;
|
|
141
|
+
if (!allowStart || this.startAttempted || process.env.ENGRAM_URL?.trim()) return false;
|
|
142
|
+
this.startAttempted = true;
|
|
143
|
+
if (!await spawnDetached(this.command, ["serve"], this.cwd)) return false;
|
|
144
|
+
await wait(650);
|
|
145
|
+
return this.health();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async submitCandidates(candidates: MemoryCandidate[]): Promise<MemoryRecord[]> {
|
|
149
|
+
const now = new Date().toISOString();
|
|
150
|
+
const records = prepareMemoryRecords(candidates, now);
|
|
151
|
+
if (records.every((record) => record.status === "rejected")) return records;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await this.ensureReady();
|
|
155
|
+
const project = await this.projectName();
|
|
156
|
+
await this.syncCloudProject(project, "import");
|
|
157
|
+
const saved: MemoryRecord[] = [];
|
|
158
|
+
for (const record of records) {
|
|
159
|
+
if (record.status === "rejected") {
|
|
160
|
+
saved.push(record);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
saved.push(await this.saveRecord(record, project));
|
|
164
|
+
}
|
|
165
|
+
await this.syncCloudProject(project, "export");
|
|
166
|
+
return saved;
|
|
167
|
+
} catch {
|
|
168
|
+
return this.fallback ? this.fallback.submitCandidates(candidates) : [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async list(status?: MemoryRecord["status"]): Promise<MemoryRecord[]> {
|
|
173
|
+
if (status && status !== "active") return [];
|
|
174
|
+
try {
|
|
175
|
+
await this.ensureReady();
|
|
176
|
+
const project = await this.projectName();
|
|
177
|
+
await this.syncCloudProject(project, "import");
|
|
178
|
+
const rows = await this.request<EngramObservation[] | null>(`/observations/recent${queryString({ project, limit: 100 })}`);
|
|
179
|
+
return observationRows(rows).map((row) => this.recordFromObservation(row));
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async pendingCount(): Promise<number> {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async approve(id: string): Promise<MemoryRecord | undefined> {
|
|
190
|
+
if (!isEngramRecordId(id)) return undefined;
|
|
191
|
+
try {
|
|
192
|
+
await this.ensureReady();
|
|
193
|
+
return this.recordFromObservation(await this.request<EngramObservation>(`/observations/${encodeURIComponent(engramNumericId(id))}`));
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async reject(id: string): Promise<MemoryRecord | undefined> {
|
|
200
|
+
if (!isEngramRecordId(id)) return undefined;
|
|
201
|
+
const record = await this.approve(id);
|
|
202
|
+
if (record) await this.delete(id);
|
|
203
|
+
return record ? { ...record, status: "rejected" } : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async delete(id: string): Promise<boolean> {
|
|
207
|
+
if (!isEngramRecordId(id)) return this.fallback ? this.fallback.delete(id) : false;
|
|
208
|
+
try {
|
|
209
|
+
await this.ensureReady();
|
|
210
|
+
const project = await this.projectName();
|
|
211
|
+
await this.request(`/observations/${encodeURIComponent(engramNumericId(id))}`, { method: "DELETE" });
|
|
212
|
+
await this.syncCloudProject(project, "export");
|
|
213
|
+
return true;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async search(query: string, limit = 10): Promise<MemorySearchResult[]> {
|
|
220
|
+
try {
|
|
221
|
+
await this.ensureReady();
|
|
222
|
+
const project = await this.projectName();
|
|
223
|
+
await this.syncCloudProject(project, "import");
|
|
224
|
+
const rows = await this.request<EngramObservation[] | null>(`/search${queryString({ q: query, project, limit })}`);
|
|
225
|
+
return observationRows(rows).map((row) => ({
|
|
226
|
+
record: this.recordFromObservation(row),
|
|
227
|
+
score: Math.abs(Number(row.rank ?? 0)),
|
|
228
|
+
highlights: [String(row.content ?? "").slice(0, 180)],
|
|
229
|
+
}));
|
|
230
|
+
} catch {
|
|
231
|
+
return this.fallback ? this.fallback.search(query, limit) : [];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async retrieve(request: MemoryContextRequest): Promise<MemoryContextBundle> {
|
|
236
|
+
const tokenBudget = memoryTokenBudget(request);
|
|
237
|
+
const results = await this.search(request.query, Math.max(request.limit ?? 8, 1));
|
|
238
|
+
const selected = selectWithinBudget(results, tokenBudget, Boolean(request.includeEvidence));
|
|
239
|
+
return {
|
|
240
|
+
text: formatEngramContext(selected.results, tokenBudget, Boolean(request.includeEvidence)),
|
|
241
|
+
results: selected.results,
|
|
242
|
+
tokenBudget,
|
|
243
|
+
estimatedTokens: selected.estimatedTokens,
|
|
244
|
+
omitted: Math.max(0, results.length - selected.results.length),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async revise(id: string, input: MemoryRevisionInput): Promise<MemoryRecord | undefined> {
|
|
249
|
+
if (!isEngramRecordId(id)) return this.fallback?.revise(id, input);
|
|
250
|
+
try {
|
|
251
|
+
await this.ensureReady();
|
|
252
|
+
const project = await this.projectName();
|
|
253
|
+
const row = await this.request<EngramObservation>(`/observations/${encodeURIComponent(engramNumericId(id))}`, {
|
|
254
|
+
method: "PATCH",
|
|
255
|
+
body: {
|
|
256
|
+
type: input.category ? engramTypeForCategory(input.category) : undefined,
|
|
257
|
+
content: engramContent(input.content, input.evidence),
|
|
258
|
+
scope: input.scope === "user" ? "personal" : input.scope,
|
|
259
|
+
topic_key: input.topicKey,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
await this.syncCloudProject(project, "export");
|
|
263
|
+
return this.recordFromObservation(row);
|
|
264
|
+
} catch {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async events(recordId?: string): Promise<MemoryAuditEvent[]> {
|
|
270
|
+
if (recordId && !isEngramRecordId(recordId)) return this.fallback ? this.fallback.events(recordId) : [];
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async cloudSyncDetail(): Promise<string | undefined> {
|
|
275
|
+
if (!this.autoSync || !isLocalEngramBaseUrl(this.baseUrl)) return undefined;
|
|
276
|
+
try {
|
|
277
|
+
const project = await this.projectName();
|
|
278
|
+
const status = await this.request<EngramSyncStatus>(`/sync/status${queryString({ project })}`);
|
|
279
|
+
if (status?.enabled && !hasCloudSyncRuntimeAuth()) {
|
|
280
|
+
return `Engram cloud sync is enabled for project "${project}", but this Pi process does not have ENGRAM_CLOUD_TOKEN. Export it before launching Pi.`;
|
|
281
|
+
}
|
|
282
|
+
if (status?.enabled && status.reason_code) {
|
|
283
|
+
const message = status.reason_message ? `: ${status.reason_message}` : "";
|
|
284
|
+
return `Engram cloud sync has a previous degraded state for project "${project}" (${status.reason_code}${message}); pi-chalin will retry automatically before reading memory.`;
|
|
285
|
+
}
|
|
286
|
+
if (!status?.enabled && status?.reason_code) {
|
|
287
|
+
const message = status.reason_message ? `: ${status.reason_message}` : "";
|
|
288
|
+
return `Engram cloud sync is blocked for project "${project}" (${status.reason_code}${message}).`;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async ensureReady(): Promise<void> {
|
|
297
|
+
if (!await this.isAvailable()) throw new Error(`Engram is unavailable at ${this.baseUrl}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async health(): Promise<boolean> {
|
|
301
|
+
try {
|
|
302
|
+
const res = await fetch(`${this.baseUrl}/health`, { signal: AbortSignal.timeout(this.timeoutMs) });
|
|
303
|
+
return res.ok;
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async request<T>(route: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
|
|
310
|
+
const res = await fetch(`${this.baseUrl}${route}`, {
|
|
311
|
+
method: options.method ?? "GET",
|
|
312
|
+
headers: options.body ? { "Content-Type": "application/json" } : undefined,
|
|
313
|
+
body: options.body ? JSON.stringify(removeUndefined(options.body)) : undefined,
|
|
314
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
315
|
+
});
|
|
316
|
+
let data: unknown;
|
|
317
|
+
try {
|
|
318
|
+
data = await res.json();
|
|
319
|
+
} catch {
|
|
320
|
+
data = undefined;
|
|
321
|
+
}
|
|
322
|
+
if (!res.ok) {
|
|
323
|
+
const message = isRecord(data) && typeof data.error === "string" ? data.error : `Engram HTTP ${res.status}`;
|
|
324
|
+
throw new Error(message);
|
|
325
|
+
}
|
|
326
|
+
return data as T;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private async projectName(): Promise<string> {
|
|
330
|
+
if (this.configuredProject) return this.configuredProject;
|
|
331
|
+
if (this.projectCache) return this.projectCache;
|
|
332
|
+
try {
|
|
333
|
+
const detected = await this.request<EngramProjectResponse>(`/project/current${queryString({ cwd: this.cwd })}`);
|
|
334
|
+
if (detected.project?.trim()) {
|
|
335
|
+
this.projectCache = detected.project.trim();
|
|
336
|
+
return this.projectCache;
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// Older Engram versions or non-running servers can still use local config.
|
|
340
|
+
}
|
|
341
|
+
const configured = readEngramProjectConfig(this.cwd);
|
|
342
|
+
this.projectCache = configured || path.basename(this.cwd).trim().toLowerCase() || "unknown";
|
|
343
|
+
return this.projectCache;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async ensureSession(project: string): Promise<string> {
|
|
347
|
+
const sessionId = `pi-chalin-${stableHash(`${project}:${this.cwd}`)}`;
|
|
348
|
+
const key = `${project}:${sessionId}`;
|
|
349
|
+
if (this.knownSessions.has(key)) return sessionId;
|
|
350
|
+
await this.request("/sessions", {
|
|
351
|
+
method: "POST",
|
|
352
|
+
body: { id: sessionId, project, directory: this.cwd },
|
|
353
|
+
});
|
|
354
|
+
this.knownSessions.add(key);
|
|
355
|
+
return sessionId;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async saveRecord(record: MemoryRecord, project: string): Promise<MemoryRecord> {
|
|
359
|
+
const sessionId = await this.ensureSession(project);
|
|
360
|
+
const response = await this.request<{ id?: number | string }>("/observations", {
|
|
361
|
+
method: "POST",
|
|
362
|
+
body: {
|
|
363
|
+
session_id: sessionId,
|
|
364
|
+
type: engramTypeForCategory(record.category),
|
|
365
|
+
title: engramTitle(record),
|
|
366
|
+
content: engramContent(record.content, record.evidence),
|
|
367
|
+
project,
|
|
368
|
+
scope: record.scope === "user" ? "personal" : "project",
|
|
369
|
+
topic_key: record.topicKey,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
const id = response.id !== undefined ? `engram-${response.id}` : record.id;
|
|
373
|
+
return { ...record, id, status: "active", reviewedAt: record.reviewedAt ?? new Date().toISOString() };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private async syncCloudProject(project: string, mode: "import" | "export"): Promise<void> {
|
|
377
|
+
if (!this.autoSync || !project.trim() || !isLocalEngramBaseUrl(this.baseUrl) || !hasCloudSyncRuntimeAuth()) return;
|
|
378
|
+
let status: EngramSyncStatus | undefined;
|
|
379
|
+
try {
|
|
380
|
+
status = await this.request<EngramSyncStatus>(`/sync/status${queryString({ project })}`);
|
|
381
|
+
} catch {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (!status?.enabled) return;
|
|
385
|
+
|
|
386
|
+
const key = `${mode}:${this.command}:${this.cwd}:${project}`;
|
|
387
|
+
const now = Date.now();
|
|
388
|
+
const previous = cloudSyncAttempts.get(key);
|
|
389
|
+
if (mode === "import" && previous && now - previous.at < this.syncThrottleMs) {
|
|
390
|
+
if (previous.promise) await previous.promise.catch(() => undefined);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const args = ["sync", "--cloud", ...(mode === "import" ? ["--import"] : []), "--project", project];
|
|
394
|
+
const promise = runEngramCommand(this.command, args, this.cwd, 20_000).then(() => undefined);
|
|
395
|
+
cloudSyncAttempts.set(key, { at: now, promise });
|
|
396
|
+
await promise.catch(() => undefined);
|
|
397
|
+
cloudSyncAttempts.set(key, { at: Date.now() });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private recordFromObservation(row: EngramObservation): MemoryRecord {
|
|
401
|
+
const createdAt = row.created_at ?? new Date().toISOString();
|
|
402
|
+
const category = row.type || "memory";
|
|
403
|
+
const content = row.content || row.title || "";
|
|
404
|
+
return {
|
|
405
|
+
id: row.id !== undefined ? `engram-${row.id}` : `engram-${stableHash(`${row.title}:${content}`)}`,
|
|
406
|
+
category,
|
|
407
|
+
content,
|
|
408
|
+
sourceAgent: "engram",
|
|
409
|
+
confidence: 0.9,
|
|
410
|
+
scope: row.scope === "personal" ? "user" : "project",
|
|
411
|
+
createdAt,
|
|
412
|
+
status: "active",
|
|
413
|
+
reviewedAt: row.updated_at ?? createdAt,
|
|
414
|
+
...(row.topic_key ? { topicKey: row.topic_key } : {}),
|
|
415
|
+
importance: importanceForEngramType(category),
|
|
416
|
+
trigger: "engram-memory",
|
|
417
|
+
lastSeenAt: row.last_seen_at ?? row.updated_at ?? createdAt,
|
|
418
|
+
duplicateCount: Number(row.duplicate_count ?? 1),
|
|
419
|
+
revisionCount: Number(row.revision_count ?? 1),
|
|
420
|
+
updatedAt: row.updated_at ?? createdAt,
|
|
421
|
+
useCount: 0,
|
|
422
|
+
utilityScore: 0.8,
|
|
423
|
+
tokenCostEstimate: estimateTokens(content),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function createConfiguredMemoryStore(options: ChalinPathsOptions, config = loadEffectiveConfig(options).config): MemoryStoreLike {
|
|
429
|
+
const local = new MemoryStore(options);
|
|
430
|
+
const provider = configuredMemoryProvider(config);
|
|
431
|
+
if (provider === "pi-chalin") return local;
|
|
432
|
+
const engram = new EngramMemoryStore({
|
|
433
|
+
...options,
|
|
434
|
+
config: config.memory.engram,
|
|
435
|
+
fallback: provider === "auto" ? local : undefined,
|
|
436
|
+
forceStart: provider === "engram",
|
|
437
|
+
});
|
|
438
|
+
return provider === "engram" ? engram : new AutoMemoryStore(engram, local);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export async function resolveMemoryBackendStatus(options: ChalinPathsOptions, config = loadEffectiveConfig(options).config): Promise<MemoryBackendStatus> {
|
|
442
|
+
const provider = configuredMemoryProvider(config);
|
|
443
|
+
if (provider === "pi-chalin") {
|
|
444
|
+
return {
|
|
445
|
+
configuredProvider: provider,
|
|
446
|
+
activeProvider: "pi-chalin",
|
|
447
|
+
engramAvailable: false,
|
|
448
|
+
summary: "pi-chalin local",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const engram = new EngramMemoryStore({
|
|
452
|
+
...options,
|
|
453
|
+
config: config.memory.engram,
|
|
454
|
+
forceStart: provider === "engram",
|
|
455
|
+
});
|
|
456
|
+
const available = await engram.isAvailable(provider === "engram" || config.memory.engram.autoStart);
|
|
457
|
+
const detail = available ? await engram.cloudSyncDetail() : undefined;
|
|
458
|
+
if (!available && provider === "engram") {
|
|
459
|
+
return {
|
|
460
|
+
configuredProvider: provider,
|
|
461
|
+
activeProvider: "unavailable",
|
|
462
|
+
engramAvailable: false,
|
|
463
|
+
summary: `engram unavailable (${engramBaseUrl(config)})`,
|
|
464
|
+
detail: "Engram is selected; the memory panel shows Engram observations only when Engram is reachable.",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
configuredProvider: provider,
|
|
469
|
+
activeProvider: available ? "engram" : "pi-chalin",
|
|
470
|
+
engramAvailable: available,
|
|
471
|
+
summary: available ? `engram (${engramBaseUrl(config)})` : `pi-chalin local (Engram unavailable at ${engramBaseUrl(config)})`,
|
|
472
|
+
...(detail ? { detail } : {}),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function configuredMemoryProvider(config: ChalinConfig): MemoryProvider {
|
|
477
|
+
const env = process.env.PI_CHALIN_MEMORY_PROVIDER?.trim();
|
|
478
|
+
if (env === "auto" || env === "engram" || env === "pi-chalin") return env;
|
|
479
|
+
return config.memory.provider;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function observationRows(rows: EngramObservation[] | null | undefined): EngramObservation[] {
|
|
483
|
+
return Array.isArray(rows) ? rows : [];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function hasCloudSyncRuntimeAuth(): boolean {
|
|
487
|
+
return Boolean(process.env.ENGRAM_CLOUD_TOKEN?.trim() || process.env.ENGRAM_CLOUD_INSECURE_NO_AUTH?.trim() === "1");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function isLocalEngramBaseUrl(baseUrl: string): boolean {
|
|
491
|
+
try {
|
|
492
|
+
const parsed = new URL(baseUrl);
|
|
493
|
+
const host = parsed.hostname.toLowerCase();
|
|
494
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
495
|
+
} catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function engramBaseUrl(config: ChalinConfig): string {
|
|
501
|
+
return resolveEngramBaseUrl(config.memory.engram);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function resolveEngramBaseUrl(config: ChalinConfig["memory"]["engram"]): string {
|
|
505
|
+
const explicitUrl = process.env.ENGRAM_URL?.trim();
|
|
506
|
+
if (explicitUrl) return explicitUrl.replace(/\/+$/, "");
|
|
507
|
+
|
|
508
|
+
const configured = config.baseUrl.replace(/\/+$/, "");
|
|
509
|
+
const defaultUrl = DEFAULT_CONFIG.memory.engram.baseUrl.replace(/\/+$/, "");
|
|
510
|
+
const port = normalizedEngramPort(process.env.ENGRAM_PORT);
|
|
511
|
+
if (port && configured === defaultUrl) return `http://127.0.0.1:${port}`;
|
|
512
|
+
|
|
513
|
+
return configured;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function normalizedEngramPort(value: string | undefined): number | undefined {
|
|
517
|
+
const port = Number.parseInt(value?.trim() ?? "", 10);
|
|
518
|
+
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function readEngramProjectConfig(cwd: string): string | undefined {
|
|
522
|
+
let current = path.resolve(cwd || ".");
|
|
523
|
+
while (true) {
|
|
524
|
+
const configPath = path.join(current, ".engram", "config.json");
|
|
525
|
+
if (fs.existsSync(configPath)) {
|
|
526
|
+
try {
|
|
527
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { project_name?: unknown };
|
|
528
|
+
const projectName = typeof parsed.project_name === "string" ? parsed.project_name.trim() : "";
|
|
529
|
+
return projectName || undefined;
|
|
530
|
+
} catch {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const parent = path.dirname(current);
|
|
535
|
+
if (parent === current) return undefined;
|
|
536
|
+
current = parent;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function queryString(params: Record<string, unknown>): string {
|
|
541
|
+
const query = new URLSearchParams();
|
|
542
|
+
for (const [key, value] of Object.entries(params)) {
|
|
543
|
+
if (value === undefined || value === null || value === "") continue;
|
|
544
|
+
query.set(key, String(value));
|
|
545
|
+
}
|
|
546
|
+
const encoded = query.toString();
|
|
547
|
+
return encoded ? `?${encoded}` : "";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function removeUndefined(value: unknown): unknown {
|
|
551
|
+
if (!isRecord(value)) return value;
|
|
552
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
556
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function spawnDetached(command: string, args: readonly string[], cwd: string): Promise<boolean> {
|
|
560
|
+
return new Promise((resolve) => {
|
|
561
|
+
try {
|
|
562
|
+
const child = spawn(command, [...args], { cwd, detached: true, stdio: "ignore" });
|
|
563
|
+
let settled = false;
|
|
564
|
+
const settle = (started: boolean) => {
|
|
565
|
+
if (settled) return;
|
|
566
|
+
settled = true;
|
|
567
|
+
resolve(started);
|
|
568
|
+
};
|
|
569
|
+
child.once("error", () => settle(false));
|
|
570
|
+
child.once("spawn", () => {
|
|
571
|
+
child.unref();
|
|
572
|
+
settle(true);
|
|
573
|
+
});
|
|
574
|
+
} catch {
|
|
575
|
+
resolve(false);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function runEngramCommand(command: string, args: readonly string[], cwd: string, timeoutMs: number): Promise<boolean> {
|
|
581
|
+
return new Promise((resolve) => {
|
|
582
|
+
try {
|
|
583
|
+
const child = spawn(command, [...args], { cwd, env: process.env, stdio: "ignore" });
|
|
584
|
+
let settled = false;
|
|
585
|
+
const settle = (ok: boolean) => {
|
|
586
|
+
if (settled) return;
|
|
587
|
+
settled = true;
|
|
588
|
+
clearTimeout(timeout);
|
|
589
|
+
resolve(ok);
|
|
590
|
+
};
|
|
591
|
+
const timeout = setTimeout(() => {
|
|
592
|
+
child.kill("SIGTERM");
|
|
593
|
+
settle(false);
|
|
594
|
+
}, timeoutMs);
|
|
595
|
+
child.once("error", () => settle(false));
|
|
596
|
+
child.once("exit", (code) => settle(code === 0));
|
|
597
|
+
} catch {
|
|
598
|
+
resolve(false);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function wait(ms: number): Promise<void> {
|
|
604
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function isEngramRecordId(id: string): boolean {
|
|
608
|
+
return /^engram-\d+$/.test(id);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function engramNumericId(id: string): string {
|
|
612
|
+
return id.replace(/^engram-/, "");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function engramTypeForCategory(category: string): string {
|
|
616
|
+
const normalized = category.toLowerCase();
|
|
617
|
+
const mapped: Record<string, string> = {
|
|
618
|
+
"project-fact": "discovery",
|
|
619
|
+
"agent-note": "learning",
|
|
620
|
+
tooling: "config",
|
|
621
|
+
testing: "pattern",
|
|
622
|
+
workflow: "pattern",
|
|
623
|
+
failure: "bugfix",
|
|
624
|
+
bugfix: "bugfix",
|
|
625
|
+
preference: "preference",
|
|
626
|
+
architecture: "architecture",
|
|
627
|
+
decision: "decision",
|
|
628
|
+
safety: "decision",
|
|
629
|
+
security: "decision",
|
|
630
|
+
};
|
|
631
|
+
return mapped[normalized] ?? normalized;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function importanceForEngramType(type: string): number {
|
|
635
|
+
if (["architecture", "decision", "preference"].includes(type)) return 0.95;
|
|
636
|
+
if (["bugfix", "pattern", "config"].includes(type)) return 0.8;
|
|
637
|
+
return 0.7;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function engramTitle(record: MemoryRecord): string {
|
|
641
|
+
const prefix = record.topicKey ? record.topicKey.split("/").at(-1) ?? record.topicKey : record.category;
|
|
642
|
+
return truncateText(`${prefix}: ${record.content}`, 90);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function engramContent(content: string, evidence?: string): string {
|
|
646
|
+
if (!evidence) return content;
|
|
647
|
+
return `${content}\n\nEvidence: ${evidence}`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function memoryTokenBudget(request: MemoryContextRequest): number {
|
|
651
|
+
if (Number.isFinite(request.tokenBudget) && (request.tokenBudget ?? 0) > 0) return Math.max(80, Math.min(1800, Math.floor(request.tokenBudget!)));
|
|
652
|
+
return 520;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function selectWithinBudget(results: MemorySearchResult[], tokenBudget: number, includeEvidence: boolean): { results: MemorySearchResult[]; estimatedTokens: number } {
|
|
656
|
+
const selected: MemorySearchResult[] = [];
|
|
657
|
+
let used = 0;
|
|
658
|
+
for (const result of results) {
|
|
659
|
+
const tokens = estimateTokens(formatEngramLine(result.record, includeEvidence));
|
|
660
|
+
if (selected.length > 0 && used + tokens > tokenBudget) continue;
|
|
661
|
+
selected.push(result);
|
|
662
|
+
used += tokens;
|
|
663
|
+
if (used >= tokenBudget) break;
|
|
664
|
+
}
|
|
665
|
+
return { results: selected, estimatedTokens: used };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function formatEngramContext(results: MemorySearchResult[], tokenBudget: number, includeEvidence: boolean): string {
|
|
669
|
+
if (results.length === 0) return "";
|
|
670
|
+
return [
|
|
671
|
+
`Engram memory context (${results.length} records, <=${tokenBudget} token budget). Treat as guidance; current repo evidence wins.`,
|
|
672
|
+
...results.map((result) => `- ${formatEngramLine(result.record, includeEvidence)}`),
|
|
673
|
+
].join("\n");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function formatEngramLine(record: MemoryRecord, includeEvidence: boolean): string {
|
|
677
|
+
const meta = [
|
|
678
|
+
record.id,
|
|
679
|
+
record.category,
|
|
680
|
+
record.topicKey ? `topic=${record.topicKey}` : undefined,
|
|
681
|
+
record.revisionCount > 1 ? `rev=${record.revisionCount}` : undefined,
|
|
682
|
+
].filter(Boolean).join(" · ");
|
|
683
|
+
const evidence = includeEvidence && record.evidence ? ` evidence=${truncateText(record.evidence, 120)}` : "";
|
|
684
|
+
return `[${meta}] ${truncateText(record.content, 260)}${evidence}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function estimateTokens(text: string): number {
|
|
688
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function truncateText(text: string, maxChars: number): string {
|
|
692
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
693
|
+
if (normalized.length <= maxChars) return normalized;
|
|
694
|
+
return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}...`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function stableHash(input: string): string {
|
|
698
|
+
let hash = 5381;
|
|
699
|
+
for (let index = 0; index < input.length; index++) hash = (hash * 33) ^ input.charCodeAt(index);
|
|
700
|
+
return (hash >>> 0).toString(16);
|
|
701
|
+
}
|