ds4-context-engine 0.1.2 → 0.2.0-beta.2
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 +84 -14
- package/docs/ARCHITECTURE.md +42 -16
- package/docs/CONTEXT_MANIFEST.md +3 -1
- package/docs/CONTEXT_PLANNER.md +9 -1
- package/docs/CONTEXT_QUALITY.md +91 -0
- package/docs/HYBRID_RETRIEVAL.md +117 -0
- package/docs/LEARNED_RANKING.md +90 -0
- package/docs/LOCAL_KV_REUSE.md +131 -0
- package/docs/MEMORY_AND_PINS.md +11 -5
- package/docs/PORTABLE_CORE.md +15 -8
- package/docs/PROJECT_KNOWLEDGE.md +26 -16
- package/docs/RELEASING.md +18 -13
- package/docs/RETRIEVAL.md +1 -1
- package/docs/ROADMAP_0.2.0.md +15 -1
- package/docs/RUNTIME_ADAPTER_KIT.md +145 -0
- package/docs/STORAGE.md +37 -7
- package/package.json +12 -7
- package/quality/corpus-v1.json +208 -0
- package/quality/semantic-corpus-v1.json +84 -0
- package/quality/symbol-corpus-v1.json +38 -0
- package/scripts/compare-context-quality.mjs +23 -0
- package/src/extension/commands.ts +213 -1
- package/src/extension/index.ts +1 -0
- package/src/extension/runtime.ts +622 -16
- package/src/pi-adapter/local-embedding.ts +132 -0
- package/src/pi-adapter/memory-adapter.ts +18 -8
- package/src/pi-adapter/project-memory-sync.ts +499 -0
- package/src/pi-adapter/ranking-adapter.ts +72 -0
- package/src/pi-adapter/runtime-contract.ts +86 -0
- package/src/pi-adapter/version.ts +3 -3
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EmbeddingModelIdentity,
|
|
3
|
+
EmbeddingPort,
|
|
4
|
+
} from "ds4-context-core/retrieval/embedding";
|
|
5
|
+
import { sha256 } from "ds4-context-core/shared/hash";
|
|
6
|
+
|
|
7
|
+
const CONCEPTS: Readonly<Record<string, string>> = {
|
|
8
|
+
auth: "auth",
|
|
9
|
+
authentication: "auth",
|
|
10
|
+
authorize: "auth",
|
|
11
|
+
authorization: "auth",
|
|
12
|
+
credential: "credential",
|
|
13
|
+
credentials: "credential",
|
|
14
|
+
password: "credential",
|
|
15
|
+
secret: "credential",
|
|
16
|
+
token: "credential",
|
|
17
|
+
cache: "cache",
|
|
18
|
+
cached: "cache",
|
|
19
|
+
caching: "cache",
|
|
20
|
+
reuse: "cache",
|
|
21
|
+
memoize: "cache",
|
|
22
|
+
database: "database",
|
|
23
|
+
datastore: "database",
|
|
24
|
+
persistence: "database",
|
|
25
|
+
sqlite: "database",
|
|
26
|
+
storage: "database",
|
|
27
|
+
crash: "failure",
|
|
28
|
+
error: "failure",
|
|
29
|
+
errors: "failure",
|
|
30
|
+
fail: "failure",
|
|
31
|
+
failed: "failure",
|
|
32
|
+
failure: "failure",
|
|
33
|
+
exception: "failure",
|
|
34
|
+
delete: "remove",
|
|
35
|
+
deleted: "remove",
|
|
36
|
+
deletion: "remove",
|
|
37
|
+
purge: "remove",
|
|
38
|
+
remove: "remove",
|
|
39
|
+
refresh: "renew",
|
|
40
|
+
renew: "renew",
|
|
41
|
+
renewal: "renew",
|
|
42
|
+
rotate: "renew",
|
|
43
|
+
expiry: "expire",
|
|
44
|
+
expired: "expire",
|
|
45
|
+
expiration: "expire",
|
|
46
|
+
timeout: "timeout",
|
|
47
|
+
timedout: "timeout",
|
|
48
|
+
latency: "performance",
|
|
49
|
+
performance: "performance",
|
|
50
|
+
slow: "performance",
|
|
51
|
+
optimize: "performance",
|
|
52
|
+
optimization: "performance",
|
|
53
|
+
validate: "validate",
|
|
54
|
+
validation: "validate",
|
|
55
|
+
verify: "validate",
|
|
56
|
+
verification: "validate",
|
|
57
|
+
check: "validate",
|
|
58
|
+
config: "configuration",
|
|
59
|
+
configuration: "configuration",
|
|
60
|
+
setting: "configuration",
|
|
61
|
+
settings: "configuration",
|
|
62
|
+
index: "index",
|
|
63
|
+
indexing: "index",
|
|
64
|
+
search: "retrieval",
|
|
65
|
+
retrieve: "retrieval",
|
|
66
|
+
retrieval: "retrieval",
|
|
67
|
+
lookup: "retrieval",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function stem(token: string): string {
|
|
71
|
+
if (token.length > 6 && token.endsWith("ing")) return token.slice(0, -3);
|
|
72
|
+
if (token.length > 5 && token.endsWith("ed")) return token.slice(0, -2);
|
|
73
|
+
if (token.length > 5 && token.endsWith("es")) return token.slice(0, -2);
|
|
74
|
+
if (token.length > 4 && token.endsWith("s")) return token.slice(0, -1);
|
|
75
|
+
return token;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function canonical(token: string): string {
|
|
79
|
+
const lower = token.toLocaleLowerCase("en-US");
|
|
80
|
+
return CONCEPTS[lower] ?? CONCEPTS[stem(lower)] ?? stem(lower);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function tokens(text: string): string[] {
|
|
84
|
+
const separated = text
|
|
85
|
+
.replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
|
|
86
|
+
.replace(/[_./\\:-]+/gu, " ")
|
|
87
|
+
.toLocaleLowerCase("en-US");
|
|
88
|
+
return [...separated.matchAll(/[\p{L}\p{N}]{2,}/gu)]
|
|
89
|
+
.map((match) => canonical(match[0]))
|
|
90
|
+
.filter((token) => token.length >= 2)
|
|
91
|
+
.slice(0, 20_000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function addFeature(vector: number[], feature: string, weight: number): void {
|
|
95
|
+
const hash = sha256(feature);
|
|
96
|
+
const bucket = Number.parseInt(hash.slice(0, 8), 16) % vector.length;
|
|
97
|
+
const sign = Number.parseInt(hash.slice(8, 10), 16) % 2 === 0 ? 1 : -1;
|
|
98
|
+
vector[bucket] = (vector[bucket] ?? 0) + weight * sign;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function embedText(text: string, dimensions: number): readonly number[] {
|
|
102
|
+
const vector = Array.from({ length: dimensions }, () => 0);
|
|
103
|
+
const values = tokens(text);
|
|
104
|
+
const frequencies = new Map<string, number>();
|
|
105
|
+
for (const value of values) frequencies.set(value, (frequencies.get(value) ?? 0) + 1);
|
|
106
|
+
for (const [value, frequency] of frequencies) {
|
|
107
|
+
addFeature(vector, `token:${value}`, 1 + Math.log(frequency));
|
|
108
|
+
}
|
|
109
|
+
for (let index = 0; index + 1 < values.length; index++) {
|
|
110
|
+
addFeature(vector, `pair:${values[index]}:${values[index + 1]}`, 0.35);
|
|
111
|
+
}
|
|
112
|
+
if (values.length === 0) addFeature(vector, "empty", 1);
|
|
113
|
+
const magnitude = Math.sqrt(vector.reduce((total, value) => total + value * value, 0));
|
|
114
|
+
return magnitude > 0 ? vector.map((value) => value / magnitude) : vector;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class LocalFeatureHashEmbedding implements EmbeddingPort {
|
|
118
|
+
readonly identity: EmbeddingModelIdentity;
|
|
119
|
+
|
|
120
|
+
constructor(dimensions = 256) {
|
|
121
|
+
this.identity = {
|
|
122
|
+
provider: "ds4-local",
|
|
123
|
+
model: "feature-hash-v1",
|
|
124
|
+
dimensions,
|
|
125
|
+
destination: "local",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
embed(texts: readonly string[]): readonly (readonly number[])[] {
|
|
130
|
+
return texts.map((text) => embedText(text, this.identity.dimensions));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readJsonlRecords } from "./session-jsonl.ts";
|
|
2
|
+
import { readJsonlRecords, type JsonlRecord } from "./session-jsonl.ts";
|
|
3
3
|
import {
|
|
4
4
|
MEMORY_CUSTOM_ENTRY_TYPE,
|
|
5
5
|
PIN_CUSTOM_ENTRY_TYPE,
|
|
@@ -37,23 +37,31 @@ export function projectSessionFileMutations(
|
|
|
37
37
|
sessionFile: string,
|
|
38
38
|
sessionId: string,
|
|
39
39
|
): SessionMutationProjection {
|
|
40
|
-
|
|
40
|
+
return projectJsonlMutations(readJsonlRecords(sessionFile).records, sessionId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function projectJsonlMutations(
|
|
44
|
+
records: readonly JsonlRecord[],
|
|
45
|
+
sessionId: string,
|
|
46
|
+
entryOrderOffset = 0,
|
|
47
|
+
): SessionMutationProjection {
|
|
48
|
+
const entries = records.flatMap((record) => {
|
|
41
49
|
const value = record.value;
|
|
42
|
-
if (value.type
|
|
50
|
+
if (value.type === "session"
|
|
43
51
|
|| typeof value.id !== "string"
|
|
44
52
|
|| (typeof value.parentId !== "string" && value.parentId !== null)
|
|
45
|
-
|| typeof value.timestamp !== "string"
|
|
46
|
-
|| typeof value.customType !== "string") {
|
|
53
|
+
|| typeof value.timestamp !== "string") {
|
|
47
54
|
return [];
|
|
48
55
|
}
|
|
49
56
|
return [value as unknown as SessionEntry];
|
|
50
57
|
});
|
|
51
|
-
return projectSessionMutations(entries, sessionId);
|
|
58
|
+
return projectSessionMutations(entries, sessionId, entryOrderOffset);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
export function projectSessionMutations(
|
|
55
62
|
entries: readonly SessionEntry[],
|
|
56
63
|
sessionId: string,
|
|
64
|
+
entryOrderOffset = 0,
|
|
57
65
|
): SessionMutationProjection {
|
|
58
66
|
const memoryMutations: StoredMemoryMutation[] = [];
|
|
59
67
|
const pinMutations: StoredPinMutation[] = [];
|
|
@@ -81,7 +89,8 @@ export function projectSessionMutations(
|
|
|
81
89
|
mutationId: parsed.mutationId,
|
|
82
90
|
sessionId,
|
|
83
91
|
createdAt,
|
|
84
|
-
entryOrder,
|
|
92
|
+
entryOrder: entryOrderOffset + entryOrder,
|
|
93
|
+
...(entry.parentId ? { sourceParentEntryId: entry.parentId } : {}),
|
|
85
94
|
payload: normalizeMemoryTimestamp(parsed, createdAt),
|
|
86
95
|
});
|
|
87
96
|
continue;
|
|
@@ -103,7 +112,8 @@ export function projectSessionMutations(
|
|
|
103
112
|
mutationId: parsed.mutationId,
|
|
104
113
|
sessionId,
|
|
105
114
|
createdAt,
|
|
106
|
-
entryOrder,
|
|
115
|
+
entryOrder: entryOrderOffset + entryOrder,
|
|
116
|
+
...(entry.parentId ? { sourceParentEntryId: entry.parentId } : {}),
|
|
107
117
|
payload: normalizePinTimestamp(parsed, createdAt),
|
|
108
118
|
});
|
|
109
119
|
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
CrossSessionMemoryDiagnostics,
|
|
5
|
+
MemoryMaterializationResult,
|
|
6
|
+
ProjectMemorySource,
|
|
7
|
+
SessionMutationProjection,
|
|
8
|
+
} from "ds4-context-core/memory/memory-types";
|
|
9
|
+
import type {
|
|
10
|
+
MemoryRepository,
|
|
11
|
+
ProjectMemorySourceCheckpoint,
|
|
12
|
+
} from "ds4-context-core/persistence/repositories/memory-repository";
|
|
13
|
+
import type { Logger } from "ds4-context-core/shared/logging";
|
|
14
|
+
import { silentLogger } from "ds4-context-core/shared/logging";
|
|
15
|
+
import { projectJsonlMutations } from "./memory-adapter.ts";
|
|
16
|
+
import {
|
|
17
|
+
hashFileRange,
|
|
18
|
+
readJsonlRecords,
|
|
19
|
+
readSessionHeaderRecord,
|
|
20
|
+
type JsonlReadResult,
|
|
21
|
+
type SessionHeaderRecord,
|
|
22
|
+
} from "./session-jsonl.ts";
|
|
23
|
+
import { PiSessionIndexer } from "./session-indexer.ts";
|
|
24
|
+
|
|
25
|
+
const SUPPORTED_SESSION_VERSION = 3;
|
|
26
|
+
|
|
27
|
+
export interface ProjectMemorySynchronizerOptions {
|
|
28
|
+
projectPath: string;
|
|
29
|
+
activeSessionId: string;
|
|
30
|
+
activeSessionFile: string;
|
|
31
|
+
maxSessions: number;
|
|
32
|
+
logger?: Logger;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function canonicalPath(path: string): string {
|
|
37
|
+
const absolute = resolve(path);
|
|
38
|
+
try {
|
|
39
|
+
return realpathSync.native(absolute);
|
|
40
|
+
} catch {
|
|
41
|
+
return absolute;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function comparablePath(path: string): string {
|
|
46
|
+
const canonical = canonicalPath(path);
|
|
47
|
+
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function errorMessage(error: unknown): string {
|
|
51
|
+
return error instanceof Error ? error.message : String(error);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sourceCheckpoint(
|
|
55
|
+
projectPath: string,
|
|
56
|
+
sessionId: string,
|
|
57
|
+
sessionFile: string,
|
|
58
|
+
header: SessionHeaderRecord,
|
|
59
|
+
read: JsonlReadResult,
|
|
60
|
+
indexedRecords: number,
|
|
61
|
+
indexedMutations: number,
|
|
62
|
+
malformedLines: number,
|
|
63
|
+
indexedAt: number,
|
|
64
|
+
previous?: ProjectMemorySourceCheckpoint,
|
|
65
|
+
): ProjectMemorySourceCheckpoint {
|
|
66
|
+
const hasNewTerminatedLine = read.checkpointHash !== undefined;
|
|
67
|
+
return {
|
|
68
|
+
projectPath,
|
|
69
|
+
sessionId,
|
|
70
|
+
sessionFile,
|
|
71
|
+
headerHash: header.rawHash,
|
|
72
|
+
fileSize: read.fileSize,
|
|
73
|
+
fileMtimeMs: read.fileMtimeMs,
|
|
74
|
+
checkpointOffset: hasNewTerminatedLine
|
|
75
|
+
? read.safeCheckpointOffset
|
|
76
|
+
: previous?.checkpointOffset ?? read.safeCheckpointOffset,
|
|
77
|
+
checkpointHashStart: hasNewTerminatedLine
|
|
78
|
+
? read.checkpointHashStart
|
|
79
|
+
: previous?.checkpointHashStart ?? read.checkpointHashStart,
|
|
80
|
+
...(hasNewTerminatedLine
|
|
81
|
+
? { checkpointHash: read.checkpointHash }
|
|
82
|
+
: previous?.checkpointHash
|
|
83
|
+
? { checkpointHash: previous.checkpointHash }
|
|
84
|
+
: {}),
|
|
85
|
+
indexedRecords,
|
|
86
|
+
indexedMutations,
|
|
87
|
+
malformedLines,
|
|
88
|
+
status: "ready",
|
|
89
|
+
indexedAt,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mutationCount(projection: SessionMutationProjection): number {
|
|
94
|
+
const projectMemories = projection.memoryMutations.filter((stored) =>
|
|
95
|
+
stored.payload.operation === "status" || stored.payload.item.scope === "project"
|
|
96
|
+
).length;
|
|
97
|
+
const projectPins = projection.pinMutations.filter((stored) =>
|
|
98
|
+
stored.payload.operation === "status" || stored.payload.item.scope === "project"
|
|
99
|
+
).length;
|
|
100
|
+
return projectMemories + projectPins;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function diagnostics(
|
|
104
|
+
enabled: boolean,
|
|
105
|
+
sources: ProjectMemorySource[],
|
|
106
|
+
discoveredSessions: number,
|
|
107
|
+
refreshedSessions: number,
|
|
108
|
+
incrementalSessions: number,
|
|
109
|
+
rebuiltSessions: number,
|
|
110
|
+
warnings: string[],
|
|
111
|
+
lastSyncAt?: number,
|
|
112
|
+
): CrossSessionMemoryDiagnostics {
|
|
113
|
+
return {
|
|
114
|
+
enabled,
|
|
115
|
+
status: enabled ? "ready" : "disabled",
|
|
116
|
+
discoveredSessions,
|
|
117
|
+
contributingSessions: sources.filter((source) =>
|
|
118
|
+
source.status === "ready" && source.indexedMutations > 0
|
|
119
|
+
).length,
|
|
120
|
+
excludedSessions: sources.filter((source) => source.status === "excluded").length,
|
|
121
|
+
unavailableSessions: sources.filter((source) =>
|
|
122
|
+
source.status === "missing" || source.status === "corrupt"
|
|
123
|
+
).length,
|
|
124
|
+
refreshedSessions,
|
|
125
|
+
incrementalSessions,
|
|
126
|
+
rebuiltSessions,
|
|
127
|
+
sources,
|
|
128
|
+
warnings,
|
|
129
|
+
...(lastSyncAt !== undefined ? { lastSyncAt } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class ProjectMemorySynchronizer {
|
|
134
|
+
private readonly projectPath: string;
|
|
135
|
+
private readonly comparableProjectPath: string;
|
|
136
|
+
private readonly logger: Logger;
|
|
137
|
+
private readonly now: () => number;
|
|
138
|
+
|
|
139
|
+
constructor(
|
|
140
|
+
private readonly repository: MemoryRepository,
|
|
141
|
+
private readonly indexer: PiSessionIndexer,
|
|
142
|
+
private readonly options: ProjectMemorySynchronizerOptions,
|
|
143
|
+
) {
|
|
144
|
+
this.projectPath = canonicalPath(options.projectPath);
|
|
145
|
+
this.comparableProjectPath = comparablePath(this.projectPath);
|
|
146
|
+
this.logger = options.logger ?? silentLogger;
|
|
147
|
+
this.now = options.now ?? Date.now;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
sync(): CrossSessionMemoryDiagnostics {
|
|
151
|
+
const warnings: string[] = [];
|
|
152
|
+
let discoveredSessions = 0;
|
|
153
|
+
let refreshedSessions = 0;
|
|
154
|
+
let incrementalSessions = 0;
|
|
155
|
+
let rebuiltSessions = 0;
|
|
156
|
+
let latestMaterializationWarnings: string[] = [];
|
|
157
|
+
const syncedAt = this.now();
|
|
158
|
+
const previousSources = this.repository.listProjectSources(this.projectPath);
|
|
159
|
+
const candidates = this.discoverCandidates(previousSources, warnings);
|
|
160
|
+
const seenSessionIds = new Set<string>();
|
|
161
|
+
|
|
162
|
+
for (const sessionFile of candidates) {
|
|
163
|
+
let header: SessionHeaderRecord;
|
|
164
|
+
try {
|
|
165
|
+
header = readSessionHeaderRecord(sessionFile);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const previous = previousSources.find((source) => source.sessionFile === sessionFile);
|
|
168
|
+
const message = `Project memory source ${sessionFile} has an invalid header: ${errorMessage(error)}`;
|
|
169
|
+
warnings.push(message);
|
|
170
|
+
if (previous) {
|
|
171
|
+
seenSessionIds.add(previous.sessionId);
|
|
172
|
+
const materialized = this.markSourceUnavailable(
|
|
173
|
+
previous.sessionId,
|
|
174
|
+
"corrupt",
|
|
175
|
+
message,
|
|
176
|
+
syncedAt,
|
|
177
|
+
);
|
|
178
|
+
if (materialized) latestMaterializationWarnings = materialized.warnings;
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const headerCwd = header.value.cwd;
|
|
184
|
+
if (typeof headerCwd !== "string"
|
|
185
|
+
|| comparablePath(headerCwd) !== this.comparableProjectPath) {
|
|
186
|
+
const previous = previousSources.find((source) => source.sessionFile === sessionFile);
|
|
187
|
+
if (previous) {
|
|
188
|
+
seenSessionIds.add(previous.sessionId);
|
|
189
|
+
const message = `Project memory source identity no longer matches ${this.projectPath}`;
|
|
190
|
+
warnings.push(`${sessionFile}: ${message}`);
|
|
191
|
+
const materialized = this.markSourceUnavailable(
|
|
192
|
+
previous.sessionId,
|
|
193
|
+
"corrupt",
|
|
194
|
+
message,
|
|
195
|
+
syncedAt,
|
|
196
|
+
);
|
|
197
|
+
if (materialized) latestMaterializationWarnings = materialized.warnings;
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const version = header.value.version;
|
|
202
|
+
if (typeof version === "number" && version > SUPPORTED_SESSION_VERSION) {
|
|
203
|
+
const message = `Unsupported Pi session format v${version}`;
|
|
204
|
+
warnings.push(`${sessionFile}: ${message}`);
|
|
205
|
+
const previous = previousSources.find((source) => source.sessionFile === sessionFile);
|
|
206
|
+
if (previous) {
|
|
207
|
+
seenSessionIds.add(previous.sessionId);
|
|
208
|
+
const materialized = this.markSourceUnavailable(
|
|
209
|
+
previous.sessionId,
|
|
210
|
+
"corrupt",
|
|
211
|
+
message,
|
|
212
|
+
syncedAt,
|
|
213
|
+
);
|
|
214
|
+
if (materialized) latestMaterializationWarnings = materialized.warnings;
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (seenSessionIds.has(header.value.id)) {
|
|
219
|
+
warnings.push(`Duplicate Pi session id ${header.value.id} ignored at ${sessionFile}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
seenSessionIds.add(header.value.id);
|
|
223
|
+
const crossSessionSource = header.value.id !== this.options.activeSessionId;
|
|
224
|
+
if (crossSessionSource) discoveredSessions++;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const synced = this.syncSource(sessionFile, header, syncedAt, warnings);
|
|
228
|
+
if (synced.mode !== "noop") latestMaterializationWarnings = synced.materializationWarnings;
|
|
229
|
+
if (crossSessionSource && synced.mode !== "noop") refreshedSessions++;
|
|
230
|
+
if (crossSessionSource && synced.mode === "incremental") incrementalSessions++;
|
|
231
|
+
if (crossSessionSource && synced.mode === "rebuild") rebuiltSessions++;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const message = errorMessage(error);
|
|
234
|
+
const disposition = header.value.id === this.options.activeSessionId
|
|
235
|
+
? "retained after refresh failure"
|
|
236
|
+
: "excluded";
|
|
237
|
+
warnings.push(`Project memory source ${header.value.id} ${disposition}: ${message}`);
|
|
238
|
+
const materialized = this.markSourceUnavailable(
|
|
239
|
+
header.value.id,
|
|
240
|
+
"corrupt",
|
|
241
|
+
message,
|
|
242
|
+
syncedAt,
|
|
243
|
+
);
|
|
244
|
+
if (materialized) latestMaterializationWarnings = materialized.warnings;
|
|
245
|
+
this.logger.warn("project_memory.source_failed", {
|
|
246
|
+
projectPath: this.projectPath,
|
|
247
|
+
sessionId: header.value.id,
|
|
248
|
+
sessionFile,
|
|
249
|
+
error: message,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const candidateSet = new Set(candidates.map((path) => resolve(path)));
|
|
255
|
+
for (const source of previousSources) {
|
|
256
|
+
if (seenSessionIds.has(source.sessionId)) continue;
|
|
257
|
+
const present = existsSync(source.sessionFile);
|
|
258
|
+
const message = present && !candidateSet.has(resolve(source.sessionFile))
|
|
259
|
+
? `Project memory source is outside memory.maxProjectSessions: ${source.sessionFile}`
|
|
260
|
+
: `Canonical Pi session file is missing: ${source.sessionFile}`;
|
|
261
|
+
const materialized = this.markSourceUnavailable(
|
|
262
|
+
source.sessionId,
|
|
263
|
+
present ? "corrupt" : "missing",
|
|
264
|
+
message,
|
|
265
|
+
syncedAt,
|
|
266
|
+
);
|
|
267
|
+
if (materialized) latestMaterializationWarnings = materialized.warnings;
|
|
268
|
+
warnings.push(message);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const allSources = this.repository.listProjectSources(this.projectPath);
|
|
272
|
+
for (const source of allSources) {
|
|
273
|
+
if (source.malformedLines > 0) {
|
|
274
|
+
warnings.push(
|
|
275
|
+
`Project memory source ${source.sessionId} contains ${source.malformedLines} malformed JSONL line(s)`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (source.lastError && !warnings.some((warning) => warning.includes(source.lastError ?? ""))) {
|
|
279
|
+
warnings.push(`Project memory source ${source.sessionId}: ${source.lastError}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
warnings.push(...latestMaterializationWarnings);
|
|
283
|
+
const uniqueWarnings = [...new Set(warnings)];
|
|
284
|
+
const sources = allSources.filter((source) =>
|
|
285
|
+
source.sessionId !== this.options.activeSessionId || source.status === "excluded"
|
|
286
|
+
);
|
|
287
|
+
const result = diagnostics(
|
|
288
|
+
true,
|
|
289
|
+
sources,
|
|
290
|
+
discoveredSessions,
|
|
291
|
+
refreshedSessions,
|
|
292
|
+
incrementalSessions,
|
|
293
|
+
rebuiltSessions,
|
|
294
|
+
uniqueWarnings,
|
|
295
|
+
syncedAt,
|
|
296
|
+
);
|
|
297
|
+
this.logger.debug("project_memory.synced", {
|
|
298
|
+
projectPath: this.projectPath,
|
|
299
|
+
discoveredSessions,
|
|
300
|
+
contributingSessions: result.contributingSessions,
|
|
301
|
+
refreshedSessions,
|
|
302
|
+
incrementalSessions,
|
|
303
|
+
rebuiltSessions,
|
|
304
|
+
warnings: uniqueWarnings.length,
|
|
305
|
+
});
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
setExcluded(sessionId: string, excluded: boolean, reason?: string): CrossSessionMemoryDiagnostics {
|
|
310
|
+
if (excluded && sessionId === this.options.activeSessionId) {
|
|
311
|
+
throw new Error("The active Pi session cannot be excluded as a project memory source");
|
|
312
|
+
}
|
|
313
|
+
const source = this.repository.listProjectSources(this.projectPath)
|
|
314
|
+
.find((candidate) => candidate.sessionId === sessionId);
|
|
315
|
+
if (!source) throw new Error(`Project memory source session ${sessionId} was not discovered`);
|
|
316
|
+
const materialized = this.repository.setProjectSourceExcluded(
|
|
317
|
+
this.projectPath,
|
|
318
|
+
sessionId,
|
|
319
|
+
excluded,
|
|
320
|
+
this.now(),
|
|
321
|
+
reason,
|
|
322
|
+
);
|
|
323
|
+
const result = this.sync();
|
|
324
|
+
return {
|
|
325
|
+
...result,
|
|
326
|
+
warnings: [...new Set([...result.warnings, ...materialized.warnings])],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private markSourceUnavailable(
|
|
331
|
+
sessionId: string,
|
|
332
|
+
status: "missing" | "corrupt",
|
|
333
|
+
message: string,
|
|
334
|
+
indexedAt: number,
|
|
335
|
+
): MemoryMaterializationResult | undefined {
|
|
336
|
+
if (sessionId === this.options.activeSessionId) {
|
|
337
|
+
this.logger.warn("project_memory.active_source_retained", {
|
|
338
|
+
projectPath: this.projectPath,
|
|
339
|
+
sessionId,
|
|
340
|
+
error: message,
|
|
341
|
+
});
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
return this.repository.markProjectSourceUnavailable(
|
|
345
|
+
this.projectPath,
|
|
346
|
+
sessionId,
|
|
347
|
+
status,
|
|
348
|
+
message,
|
|
349
|
+
indexedAt,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private discoverCandidates(
|
|
354
|
+
previousSources: readonly ProjectMemorySource[],
|
|
355
|
+
warnings: string[],
|
|
356
|
+
): string[] {
|
|
357
|
+
const directory = dirname(this.options.activeSessionFile);
|
|
358
|
+
let names: string[] = [];
|
|
359
|
+
try {
|
|
360
|
+
names = readdirSync(directory, { withFileTypes: true })
|
|
361
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
362
|
+
.map((entry) => join(directory, entry.name))
|
|
363
|
+
.sort((left, right) => right.localeCompare(left));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
warnings.push(`Unable to enumerate Pi project sessions in ${directory}: ${errorMessage(error)}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const active = resolve(this.options.activeSessionFile);
|
|
369
|
+
const siblings = names.filter((path) => resolve(path) !== active);
|
|
370
|
+
if (siblings.length > this.options.maxSessions) {
|
|
371
|
+
warnings.push(
|
|
372
|
+
`Project session discovery capped at memory.maxProjectSessions (${this.options.maxSessions})`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
names = [active, ...siblings.slice(0, this.options.maxSessions)];
|
|
376
|
+
for (const source of previousSources) {
|
|
377
|
+
if (source.sessionId === this.options.activeSessionId
|
|
378
|
+
|| names.includes(source.sessionFile)
|
|
379
|
+
|| !existsSync(source.sessionFile)) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (names.length - 1 >= this.options.maxSessions) break;
|
|
383
|
+
names.push(source.sessionFile);
|
|
384
|
+
}
|
|
385
|
+
return [...new Set(names)];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private syncSource(
|
|
389
|
+
sessionFile: string,
|
|
390
|
+
header: SessionHeaderRecord,
|
|
391
|
+
indexedAt: number,
|
|
392
|
+
warnings: string[],
|
|
393
|
+
): { mode: "noop" | "incremental" | "rebuild"; materializationWarnings: string[] } {
|
|
394
|
+
const sessionId = header.value.id;
|
|
395
|
+
const previous = this.repository.getProjectSourceState(this.projectPath, sessionId);
|
|
396
|
+
const stat = statSync(sessionFile);
|
|
397
|
+
const rebuildReason = this.rebuildReason(sessionFile, header, stat.size, stat.mtimeMs, previous);
|
|
398
|
+
|
|
399
|
+
this.indexer.sync({
|
|
400
|
+
sessionId,
|
|
401
|
+
sessionFile,
|
|
402
|
+
projectPath: this.projectPath,
|
|
403
|
+
totalEntries: 0,
|
|
404
|
+
branchEntries: 0,
|
|
405
|
+
}, Boolean(rebuildReason));
|
|
406
|
+
|
|
407
|
+
if (!rebuildReason && previous
|
|
408
|
+
&& stat.size === previous.fileSize
|
|
409
|
+
&& stat.mtimeMs === previous.fileMtimeMs
|
|
410
|
+
&& previous.status === "ready") {
|
|
411
|
+
return { mode: "noop", materializationWarnings: [] };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (rebuildReason || !previous) {
|
|
415
|
+
const read = readJsonlRecords(sessionFile, 0);
|
|
416
|
+
const first = read.records[0];
|
|
417
|
+
if (!first || first.value.type !== "session" || first.rawHash !== header.rawHash) {
|
|
418
|
+
throw new Error("Pi session header changed during project memory replay");
|
|
419
|
+
}
|
|
420
|
+
const projection = projectJsonlMutations(read.records.slice(1), sessionId);
|
|
421
|
+
const records = read.records.filter((record) => record.value.type !== "session").length;
|
|
422
|
+
const materialized = this.repository.replaceProjectSource(
|
|
423
|
+
sourceCheckpoint(
|
|
424
|
+
this.projectPath,
|
|
425
|
+
sessionId,
|
|
426
|
+
sessionFile,
|
|
427
|
+
header,
|
|
428
|
+
read,
|
|
429
|
+
records,
|
|
430
|
+
mutationCount(projection),
|
|
431
|
+
read.malformedLines,
|
|
432
|
+
indexedAt,
|
|
433
|
+
),
|
|
434
|
+
projection.memoryMutations,
|
|
435
|
+
projection.pinMutations,
|
|
436
|
+
);
|
|
437
|
+
warnings.push(
|
|
438
|
+
...projection.warnings.map((warning) => `${sessionId}: ${warning}`),
|
|
439
|
+
);
|
|
440
|
+
return { mode: "rebuild", materializationWarnings: materialized.warnings };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const read = readJsonlRecords(sessionFile, previous.checkpointOffset);
|
|
444
|
+
const projection = projectJsonlMutations(read.records, sessionId, previous.indexedRecords);
|
|
445
|
+
const records = read.records.filter((record) => record.value.type !== "session").length;
|
|
446
|
+
const materialized = this.repository.appendProjectSource(
|
|
447
|
+
sourceCheckpoint(
|
|
448
|
+
this.projectPath,
|
|
449
|
+
sessionId,
|
|
450
|
+
sessionFile,
|
|
451
|
+
header,
|
|
452
|
+
read,
|
|
453
|
+
previous.indexedRecords + records,
|
|
454
|
+
previous.indexedMutations + mutationCount(projection),
|
|
455
|
+
previous.malformedLines + read.malformedLines,
|
|
456
|
+
indexedAt,
|
|
457
|
+
previous,
|
|
458
|
+
),
|
|
459
|
+
projection.memoryMutations,
|
|
460
|
+
projection.pinMutations,
|
|
461
|
+
);
|
|
462
|
+
warnings.push(
|
|
463
|
+
...projection.warnings.map((warning) => `${sessionId}: ${warning}`),
|
|
464
|
+
);
|
|
465
|
+
return { mode: "incremental", materializationWarnings: materialized.warnings };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private rebuildReason(
|
|
469
|
+
sessionFile: string,
|
|
470
|
+
header: SessionHeaderRecord,
|
|
471
|
+
fileSize: number,
|
|
472
|
+
fileMtimeMs: number,
|
|
473
|
+
previous?: ProjectMemorySourceCheckpoint,
|
|
474
|
+
): string | undefined {
|
|
475
|
+
if (!previous) return "initial project memory replay";
|
|
476
|
+
if (previous.status !== "ready") return `source status is ${previous.status}`;
|
|
477
|
+
if (previous.sessionFile !== sessionFile) return "session file path changed";
|
|
478
|
+
if (previous.headerHash !== header.rawHash) return "session header changed";
|
|
479
|
+
if (fileSize < previous.fileSize) return "session file was truncated";
|
|
480
|
+
if (fileSize === previous.fileSize && fileMtimeMs !== previous.fileMtimeMs) {
|
|
481
|
+
return "session file changed in place";
|
|
482
|
+
}
|
|
483
|
+
if (fileSize > previous.fileSize && previous.checkpointOffset < previous.fileSize) {
|
|
484
|
+
return "session grew after an unterminated JSONL tail";
|
|
485
|
+
}
|
|
486
|
+
if (previous.checkpointOffset > 0) {
|
|
487
|
+
if (!previous.checkpointHash) return "project memory checkpoint hash is missing";
|
|
488
|
+
const currentHash = hashFileRange(
|
|
489
|
+
sessionFile,
|
|
490
|
+
previous.checkpointHashStart,
|
|
491
|
+
previous.checkpointOffset,
|
|
492
|
+
);
|
|
493
|
+
if (currentHash !== previous.checkpointHash) {
|
|
494
|
+
return "project memory checkpoint no longer matches the JSONL file";
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
}
|