pi-readseek 0.1.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/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- package/src/write.ts +532 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const BASE10_INT_RE = /^-?\d+$/;
|
|
2
|
+
|
|
3
|
+
type CoercedIntResult =
|
|
4
|
+
| { ok: true; value: number | undefined }
|
|
5
|
+
| { ok: false; message: string };
|
|
6
|
+
|
|
7
|
+
export function coerceObviousBase10Int(value: unknown): unknown;
|
|
8
|
+
export function coerceObviousBase10Int(value: unknown, name: string): CoercedIntResult;
|
|
9
|
+
export function coerceObviousBase10Int(value: unknown, name?: string): unknown | CoercedIntResult {
|
|
10
|
+
if (name === undefined) {
|
|
11
|
+
if (typeof value === "string" && BASE10_INT_RE.test(value)) {
|
|
12
|
+
return Number.parseInt(value, 10);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (value === undefined) {
|
|
18
|
+
return { ok: true, value: undefined };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof value === "number") {
|
|
22
|
+
if (Number.isInteger(value)) {
|
|
23
|
+
return { ok: true, value };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
message: `Invalid ${name}: expected a base-10 integer, received ${value}.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof value === "string" && BASE10_INT_RE.test(value)) {
|
|
32
|
+
return { ok: true, value: Number.parseInt(value, 10) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
message: `Invalid ${name}: expected a base-10 integer, received ${JSON.stringify(value)}.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderStaleContextPlaceholder,
|
|
3
|
+
type ContextHygieneReport,
|
|
4
|
+
type ContextHygieneStaleRecord,
|
|
5
|
+
} from "./context-hygiene.js";
|
|
6
|
+
|
|
7
|
+
type ContextToolResultMessage = {
|
|
8
|
+
role?: unknown;
|
|
9
|
+
toolCallId?: unknown;
|
|
10
|
+
toolName?: unknown;
|
|
11
|
+
content?: unknown;
|
|
12
|
+
details?: unknown;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
17
|
+
return !!value && typeof value === "object";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isMaskableStaleTool(tool: string): boolean {
|
|
21
|
+
return tool === "read" || tool === "grep" || tool === "search";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function staleRecordsByResultId(report: ContextHygieneReport): Map<string, ContextHygieneStaleRecord> {
|
|
25
|
+
const records = new Map<string, ContextHygieneStaleRecord>();
|
|
26
|
+
for (const candidate of report.staleCandidates) {
|
|
27
|
+
for (const record of candidate.staleResults) {
|
|
28
|
+
if (!record.originalResultId || !isMaskableStaleTool(record.originalTool)) continue;
|
|
29
|
+
const existing = records.get(record.originalResultId);
|
|
30
|
+
if (!existing || existing.invalidatingMutationEventId < record.invalidatingMutationEventId) {
|
|
31
|
+
records.set(record.originalResultId, record);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return records;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function maskStaleToolResultMessage<T extends ContextToolResultMessage>(message: T, record: ContextHygieneStaleRecord): T {
|
|
39
|
+
const details = isRecord(message.details) ? message.details : {};
|
|
40
|
+
return {
|
|
41
|
+
...message,
|
|
42
|
+
content: [{ type: "text" as const, text: renderStaleContextPlaceholder(record) }],
|
|
43
|
+
details: {
|
|
44
|
+
...details,
|
|
45
|
+
contextHygieneStale: record,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function applyContextHygieneStaleContext<T extends ContextToolResultMessage>(
|
|
51
|
+
messages: readonly T[],
|
|
52
|
+
report: ContextHygieneReport,
|
|
53
|
+
): T[] {
|
|
54
|
+
const staleByResultId = staleRecordsByResultId(report);
|
|
55
|
+
if (staleByResultId.size === 0) return messages as T[];
|
|
56
|
+
|
|
57
|
+
let changed = false;
|
|
58
|
+
const nextMessages = messages.map((message) => {
|
|
59
|
+
if (message.role !== "toolResult" || typeof message.toolCallId !== "string") return message;
|
|
60
|
+
const staleRecord = staleByResultId.get(message.toolCallId);
|
|
61
|
+
if (staleRecord) {
|
|
62
|
+
if (message.toolName !== staleRecord.originalTool) return message;
|
|
63
|
+
changed = true;
|
|
64
|
+
return maskStaleToolResultMessage(message, staleRecord);
|
|
65
|
+
}
|
|
66
|
+
return message;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return changed ? nextMessages : (messages as T[]);
|
|
70
|
+
}
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/** Metadata used to mask stale read/search results after this extension mutates a file. */
|
|
2
|
+
|
|
3
|
+
export const CONTEXT_HYGIENE_SCHEMA_VERSION = 1 as const;
|
|
4
|
+
export const DEFAULT_CONTEXT_HYGIENE_MAX_EVENTS = 1000;
|
|
5
|
+
|
|
6
|
+
export type ContextHygieneClassification = "read-context" | "search-context" | "mutation";
|
|
7
|
+
|
|
8
|
+
export type ContextHygieneResourceKind = "file" | "symbol";
|
|
9
|
+
|
|
10
|
+
export interface ContextHygieneFileResource {
|
|
11
|
+
kind: "file";
|
|
12
|
+
key: string;
|
|
13
|
+
path: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ContextHygieneSymbolResource {
|
|
17
|
+
kind: "symbol";
|
|
18
|
+
key: string;
|
|
19
|
+
path: string;
|
|
20
|
+
symbolName: string;
|
|
21
|
+
symbolKind?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ContextHygieneResource = ContextHygieneFileResource | ContextHygieneSymbolResource;
|
|
25
|
+
|
|
26
|
+
export interface ContextHygieneReadRehydrateInput {
|
|
27
|
+
path: string;
|
|
28
|
+
offset?: number | string;
|
|
29
|
+
limit?: number | string;
|
|
30
|
+
symbol?: string;
|
|
31
|
+
map?: true;
|
|
32
|
+
bundle?: "local";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ContextHygieneGrepRehydrateInput {
|
|
36
|
+
pattern: string;
|
|
37
|
+
path?: string;
|
|
38
|
+
glob?: string;
|
|
39
|
+
literal?: true;
|
|
40
|
+
ignoreCase?: true;
|
|
41
|
+
context?: number | string;
|
|
42
|
+
summary?: true;
|
|
43
|
+
scope?: "symbol";
|
|
44
|
+
scopeContext?: number | string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ContextHygieneSearchRehydrateInput {
|
|
48
|
+
pattern: string;
|
|
49
|
+
lang?: string;
|
|
50
|
+
path?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ContextHygieneReadRehydrateDescriptor {
|
|
54
|
+
tool: "read";
|
|
55
|
+
input: ContextHygieneReadRehydrateInput;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ContextHygieneGrepRehydrateDescriptor {
|
|
59
|
+
tool: "grep";
|
|
60
|
+
input: ContextHygieneGrepRehydrateInput;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ContextHygieneSearchRehydrateDescriptor {
|
|
64
|
+
tool: "search";
|
|
65
|
+
input: ContextHygieneSearchRehydrateInput;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ContextHygieneRehydrateDescriptor =
|
|
69
|
+
| ContextHygieneReadRehydrateDescriptor
|
|
70
|
+
| ContextHygieneGrepRehydrateDescriptor
|
|
71
|
+
| ContextHygieneSearchRehydrateDescriptor;
|
|
72
|
+
|
|
73
|
+
export type ContextHygieneStaleInvalidationReason = "mutation-after-read";
|
|
74
|
+
|
|
75
|
+
export interface ContextHygieneStaleRecord {
|
|
76
|
+
status: "stale";
|
|
77
|
+
originalTool: string;
|
|
78
|
+
originalEventId?: number;
|
|
79
|
+
originalResultId?: string;
|
|
80
|
+
staleResourceKeys: string[];
|
|
81
|
+
invalidatingMutationEventId: number;
|
|
82
|
+
invalidatingMutationResultId?: string;
|
|
83
|
+
reason: ContextHygieneStaleInvalidationReason;
|
|
84
|
+
rehydrate?: ContextHygieneRehydrateDescriptor;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface BuildStaleContextRecordInput {
|
|
88
|
+
originalTool: string;
|
|
89
|
+
originalEventId?: number;
|
|
90
|
+
originalResultId?: string;
|
|
91
|
+
staleResourceKeys: readonly string[];
|
|
92
|
+
invalidatingMutationEventId: number;
|
|
93
|
+
invalidatingMutationResultId?: string;
|
|
94
|
+
reason?: ContextHygieneStaleInvalidationReason;
|
|
95
|
+
rehydrate?: ContextHygieneRehydrateDescriptor;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function cloneContextHygieneRehydrateDescriptor(
|
|
99
|
+
descriptor: ContextHygieneRehydrateDescriptor,
|
|
100
|
+
): ContextHygieneRehydrateDescriptor {
|
|
101
|
+
switch (descriptor.tool) {
|
|
102
|
+
case "read":
|
|
103
|
+
return { tool: "read", input: { ...descriptor.input } };
|
|
104
|
+
case "grep":
|
|
105
|
+
return { tool: "grep", input: { ...descriptor.input } };
|
|
106
|
+
case "search":
|
|
107
|
+
return { tool: "search", input: { ...descriptor.input } };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildStaleContextRecord(input: BuildStaleContextRecordInput): ContextHygieneStaleRecord {
|
|
112
|
+
const record: ContextHygieneStaleRecord = {
|
|
113
|
+
status: "stale",
|
|
114
|
+
originalTool: input.originalTool,
|
|
115
|
+
staleResourceKeys: sortResourceKeys(new Set(input.staleResourceKeys)),
|
|
116
|
+
invalidatingMutationEventId: input.invalidatingMutationEventId,
|
|
117
|
+
reason: input.reason ?? "mutation-after-read",
|
|
118
|
+
};
|
|
119
|
+
if (input.originalEventId !== undefined) record.originalEventId = input.originalEventId;
|
|
120
|
+
if (input.originalResultId) record.originalResultId = input.originalResultId;
|
|
121
|
+
if (input.invalidatingMutationResultId) record.invalidatingMutationResultId = input.invalidatingMutationResultId;
|
|
122
|
+
if (input.rehydrate) record.rehydrate = cloneContextHygieneRehydrateDescriptor(input.rehydrate);
|
|
123
|
+
return record;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function renderStaleReadPlaceholder(): string {
|
|
127
|
+
return "[Stale read result — this earlier read was superseded by a later file change; nothing is wrong with read. Run read again for current content.]";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function renderStaleGrepPlaceholder(): string {
|
|
131
|
+
return "[Stale grep result — this earlier grep was superseded by a later file change; nothing is wrong with grep. Run grep again for current matches.]";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function renderStaleSearchPlaceholder(): string {
|
|
135
|
+
return "[Stale search result — this earlier search was superseded by a later file change; nothing is wrong with search. Run search again for current matches.]";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function renderStaleContextPlaceholder(record: ContextHygieneStaleRecord): string {
|
|
139
|
+
switch (record.originalTool) {
|
|
140
|
+
case "read":
|
|
141
|
+
return renderStaleReadPlaceholder();
|
|
142
|
+
case "grep":
|
|
143
|
+
return renderStaleGrepPlaceholder();
|
|
144
|
+
case "search":
|
|
145
|
+
return renderStaleSearchPlaceholder();
|
|
146
|
+
default:
|
|
147
|
+
return "[Stale tool context: resource content changed after this result. Re-run the original tool to refresh.]";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface BuildReadRehydrateDescriptorInput {
|
|
152
|
+
path: string;
|
|
153
|
+
offset?: number | string;
|
|
154
|
+
limit?: number | string;
|
|
155
|
+
symbol?: string;
|
|
156
|
+
map?: boolean;
|
|
157
|
+
bundle?: "local";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function buildReadRehydrateDescriptor(
|
|
161
|
+
input: BuildReadRehydrateDescriptorInput,
|
|
162
|
+
): ContextHygieneReadRehydrateDescriptor {
|
|
163
|
+
const descriptorInput: ContextHygieneReadRehydrateInput = { path: input.path };
|
|
164
|
+
if (input.offset !== undefined) descriptorInput.offset = input.offset;
|
|
165
|
+
if (input.limit !== undefined) descriptorInput.limit = input.limit;
|
|
166
|
+
if (input.symbol !== undefined) descriptorInput.symbol = input.symbol;
|
|
167
|
+
if (input.map === true) descriptorInput.map = true;
|
|
168
|
+
if (input.bundle !== undefined) descriptorInput.bundle = input.bundle;
|
|
169
|
+
return { tool: "read", input: descriptorInput };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface BuildGrepRehydrateDescriptorInput {
|
|
173
|
+
pattern: string;
|
|
174
|
+
path?: string;
|
|
175
|
+
glob?: string;
|
|
176
|
+
literal?: boolean;
|
|
177
|
+
ignoreCase?: boolean;
|
|
178
|
+
context?: number | string;
|
|
179
|
+
summary?: boolean;
|
|
180
|
+
scope?: "symbol";
|
|
181
|
+
scopeContext?: number | string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildGrepRehydrateDescriptor(
|
|
185
|
+
input: BuildGrepRehydrateDescriptorInput,
|
|
186
|
+
): ContextHygieneGrepRehydrateDescriptor {
|
|
187
|
+
const descriptorInput: ContextHygieneGrepRehydrateInput = { pattern: input.pattern };
|
|
188
|
+
if (input.path !== undefined) descriptorInput.path = input.path;
|
|
189
|
+
if (input.glob !== undefined) descriptorInput.glob = input.glob;
|
|
190
|
+
if (input.literal === true) descriptorInput.literal = true;
|
|
191
|
+
if (input.ignoreCase === true) descriptorInput.ignoreCase = true;
|
|
192
|
+
if (input.context !== undefined) descriptorInput.context = input.context;
|
|
193
|
+
if (input.summary === true) descriptorInput.summary = true;
|
|
194
|
+
if (input.scope !== undefined) descriptorInput.scope = input.scope;
|
|
195
|
+
if (input.scopeContext !== undefined) descriptorInput.scopeContext = input.scopeContext;
|
|
196
|
+
return { tool: "grep", input: descriptorInput };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface BuildSearchRehydrateDescriptorInput {
|
|
200
|
+
pattern: string;
|
|
201
|
+
lang?: string;
|
|
202
|
+
path?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function buildSearchRehydrateDescriptor(
|
|
206
|
+
input: BuildSearchRehydrateDescriptorInput,
|
|
207
|
+
): ContextHygieneSearchRehydrateDescriptor {
|
|
208
|
+
const descriptorInput: ContextHygieneSearchRehydrateInput = { pattern: input.pattern };
|
|
209
|
+
if (input.lang !== undefined) descriptorInput.lang = input.lang;
|
|
210
|
+
if (input.path !== undefined) descriptorInput.path = input.path;
|
|
211
|
+
return { tool: "search", input: descriptorInput };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ContextHygieneMetadata {
|
|
215
|
+
schemaVersion: typeof CONTEXT_HYGIENE_SCHEMA_VERSION;
|
|
216
|
+
tool: string;
|
|
217
|
+
classification: ContextHygieneClassification;
|
|
218
|
+
resources: ContextHygieneResource[];
|
|
219
|
+
rehydrate?: ContextHygieneRehydrateDescriptor;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface BuildContextHygieneMetadataInput {
|
|
223
|
+
tool: string;
|
|
224
|
+
classification: ContextHygieneClassification;
|
|
225
|
+
resources?: readonly (ContextHygieneResource | null | undefined)[];
|
|
226
|
+
rehydrate?: ContextHygieneRehydrateDescriptor | null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function normalizePathForContextHygiene(path: string): string {
|
|
230
|
+
if (path === "") return "";
|
|
231
|
+
|
|
232
|
+
const slashPath = path.replace(/\\+/g, "/");
|
|
233
|
+
const isAbsolute = slashPath.startsWith("/");
|
|
234
|
+
const parts: string[] = [];
|
|
235
|
+
|
|
236
|
+
for (const part of slashPath.split("/")) {
|
|
237
|
+
if (!part || part === ".") continue;
|
|
238
|
+
if (part === "..") {
|
|
239
|
+
if (parts.length > 0 && parts[parts.length - 1] !== "..") {
|
|
240
|
+
parts.pop();
|
|
241
|
+
} else if (!isAbsolute) {
|
|
242
|
+
parts.push(part);
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
parts.push(part);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const normalized = `${isAbsolute ? "/" : ""}${parts.join("/")}`;
|
|
250
|
+
return normalized || (isAbsolute ? "/" : ".");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function buildFileResource(path: string): ContextHygieneFileResource {
|
|
254
|
+
const normalizedPath = normalizePathForContextHygiene(path);
|
|
255
|
+
return {
|
|
256
|
+
kind: "file",
|
|
257
|
+
key: `file:${normalizedPath}`,
|
|
258
|
+
path: normalizedPath,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function buildSymbolResource(
|
|
263
|
+
path: string,
|
|
264
|
+
symbolName: string,
|
|
265
|
+
symbolKind?: string,
|
|
266
|
+
): ContextHygieneSymbolResource {
|
|
267
|
+
const normalizedPath = normalizePathForContextHygiene(path);
|
|
268
|
+
const normalizedKind = symbolKind?.trim();
|
|
269
|
+
const keyPayload = JSON.stringify([normalizedPath, normalizedKind ?? "", symbolName]);
|
|
270
|
+
const resource: ContextHygieneSymbolResource = {
|
|
271
|
+
kind: "symbol",
|
|
272
|
+
key: `symbol:${keyPayload}`,
|
|
273
|
+
path: normalizedPath,
|
|
274
|
+
symbolName,
|
|
275
|
+
};
|
|
276
|
+
if (normalizedKind) resource.symbolKind = normalizedKind;
|
|
277
|
+
return resource;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function buildContextHygieneMetadata(
|
|
281
|
+
input: BuildContextHygieneMetadataInput,
|
|
282
|
+
): ContextHygieneMetadata {
|
|
283
|
+
const resources: ContextHygieneResource[] = [];
|
|
284
|
+
const seenResourceKeys = new Set<string>();
|
|
285
|
+
|
|
286
|
+
for (const resource of input.resources ?? []) {
|
|
287
|
+
if (!resource || seenResourceKeys.has(resource.key)) continue;
|
|
288
|
+
seenResourceKeys.add(resource.key);
|
|
289
|
+
resources.push({ ...resource } as ContextHygieneResource);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const metadata: ContextHygieneMetadata = {
|
|
293
|
+
schemaVersion: CONTEXT_HYGIENE_SCHEMA_VERSION,
|
|
294
|
+
tool: input.tool,
|
|
295
|
+
classification: input.classification,
|
|
296
|
+
resources,
|
|
297
|
+
};
|
|
298
|
+
if (input.rehydrate) metadata.rehydrate = cloneContextHygieneRehydrateDescriptor(input.rehydrate);
|
|
299
|
+
return metadata;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface ContextHygieneRecordOptions {
|
|
303
|
+
resultId?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface ContextHygieneEvent {
|
|
307
|
+
id: number;
|
|
308
|
+
resultId?: string;
|
|
309
|
+
tool: string;
|
|
310
|
+
classification: ContextHygieneClassification;
|
|
311
|
+
resources: ContextHygieneResource[];
|
|
312
|
+
rehydrate?: ContextHygieneRehydrateDescriptor;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface ContextHygieneReuseReportEntry {
|
|
316
|
+
resourceKey: string;
|
|
317
|
+
count: number;
|
|
318
|
+
eventIds: number[];
|
|
319
|
+
resultIds: string[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface ContextHygieneMutationAfterReadReportEntry {
|
|
323
|
+
resourceKey: string;
|
|
324
|
+
readEventIds: number[];
|
|
325
|
+
mutationEventId: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface ContextHygieneStaleCandidateReportEntry {
|
|
329
|
+
resourceKey: string;
|
|
330
|
+
staleEventIds: number[];
|
|
331
|
+
mutationEventId: number;
|
|
332
|
+
reason: ContextHygieneStaleInvalidationReason;
|
|
333
|
+
staleResults: ContextHygieneStaleRecord[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface ContextHygieneReport {
|
|
337
|
+
eventCount: number;
|
|
338
|
+
resourceCount: number;
|
|
339
|
+
readReuse: ContextHygieneReuseReportEntry[];
|
|
340
|
+
mutationAfterRead: ContextHygieneMutationAfterReadReportEntry[];
|
|
341
|
+
staleCandidates: ContextHygieneStaleCandidateReportEntry[];
|
|
342
|
+
churn: {
|
|
343
|
+
byClassification: Record<ContextHygieneClassification, number>;
|
|
344
|
+
byTool: Record<string, number>;
|
|
345
|
+
uniqueResourcesSeen: number;
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface ContextHygieneTracker {
|
|
350
|
+
record(metadata: ContextHygieneMetadata, options?: ContextHygieneRecordOptions): ContextHygieneEvent;
|
|
351
|
+
generateReport(): ContextHygieneReport;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface CreateContextHygieneTrackerOptions {
|
|
355
|
+
maxEvents?: number;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function resultIdsForEvents(events: ContextHygieneEvent[]): string[] {
|
|
359
|
+
return events.map((event) => event.resultId).filter((resultId): resultId is string => Boolean(resultId));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function cloneContextHygieneEvent(event: ContextHygieneEvent): ContextHygieneEvent {
|
|
363
|
+
const cloned: ContextHygieneEvent = {
|
|
364
|
+
...event,
|
|
365
|
+
resources: event.resources.map((resource) => ({ ...resource } as ContextHygieneResource)),
|
|
366
|
+
};
|
|
367
|
+
if (event.rehydrate) cloned.rehydrate = cloneContextHygieneRehydrateDescriptor(event.rehydrate);
|
|
368
|
+
return cloned;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function compareStable(left: string, right: string): number {
|
|
372
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function sortResourceKeys(keys: Iterable<string>): string[] {
|
|
376
|
+
return [...keys].sort(compareStable);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function createEmptyClassificationCounts(): Record<ContextHygieneClassification, number> {
|
|
380
|
+
return {
|
|
381
|
+
mutation: 0,
|
|
382
|
+
"read-context": 0,
|
|
383
|
+
"search-context": 0,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
class DefaultContextHygieneTracker implements ContextHygieneTracker {
|
|
388
|
+
private readonly events: ContextHygieneEvent[] = [];
|
|
389
|
+
private readonly maxEvents: number;
|
|
390
|
+
private nextEventId = 1;
|
|
391
|
+
|
|
392
|
+
constructor(options: CreateContextHygieneTrackerOptions = {}) {
|
|
393
|
+
this.maxEvents = Math.max(1, Math.floor(options.maxEvents ?? DEFAULT_CONTEXT_HYGIENE_MAX_EVENTS));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
record(metadata: ContextHygieneMetadata, options: ContextHygieneRecordOptions = {}): ContextHygieneEvent {
|
|
397
|
+
const event: ContextHygieneEvent = {
|
|
398
|
+
id: this.nextEventId++,
|
|
399
|
+
tool: metadata.tool,
|
|
400
|
+
classification: metadata.classification,
|
|
401
|
+
resources: metadata.resources.map((resource) => ({ ...resource } as ContextHygieneResource)),
|
|
402
|
+
};
|
|
403
|
+
if (options.resultId) event.resultId = options.resultId;
|
|
404
|
+
if (metadata.rehydrate) event.rehydrate = cloneContextHygieneRehydrateDescriptor(metadata.rehydrate);
|
|
405
|
+
this.events.push(event);
|
|
406
|
+
if (this.events.length > this.maxEvents) this.events.splice(0, this.events.length - this.maxEvents);
|
|
407
|
+
return cloneContextHygieneEvent(event);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
generateReport(): ContextHygieneReport {
|
|
411
|
+
const eventsByResource = new Map<string, ContextHygieneEvent[]>();
|
|
412
|
+
const readEventsByResource = new Map<string, ContextHygieneEvent[]>();
|
|
413
|
+
const mutationEventsByResource = new Map<string, ContextHygieneEvent[]>();
|
|
414
|
+
const byClassification = createEmptyClassificationCounts();
|
|
415
|
+
const byTool: Record<string, number> = {};
|
|
416
|
+
|
|
417
|
+
for (const event of this.events) {
|
|
418
|
+
byClassification[event.classification] += 1;
|
|
419
|
+
byTool[event.tool] = (byTool[event.tool] ?? 0) + 1;
|
|
420
|
+
|
|
421
|
+
for (const resource of event.resources) {
|
|
422
|
+
const bucket = eventsByResource.get(resource.key) ?? [];
|
|
423
|
+
bucket.push(event);
|
|
424
|
+
eventsByResource.set(resource.key, bucket);
|
|
425
|
+
|
|
426
|
+
if (event.classification === "read-context" || event.classification === "search-context") {
|
|
427
|
+
const readBucket = readEventsByResource.get(resource.key) ?? [];
|
|
428
|
+
readBucket.push(event);
|
|
429
|
+
readEventsByResource.set(resource.key, readBucket);
|
|
430
|
+
}
|
|
431
|
+
if (event.classification === "mutation") {
|
|
432
|
+
const mutationBucket = mutationEventsByResource.get(resource.key) ?? [];
|
|
433
|
+
mutationBucket.push(event);
|
|
434
|
+
mutationEventsByResource.set(resource.key, mutationBucket);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const readReuse = sortResourceKeys(readEventsByResource.keys()).flatMap((resourceKey) => {
|
|
440
|
+
const events = readEventsByResource.get(resourceKey) ?? [];
|
|
441
|
+
if (events.length < 2) return [];
|
|
442
|
+
return [{ resourceKey, count: events.length, eventIds: events.map((event) => event.id), resultIds: resultIdsForEvents(events) }];
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const mutationAfterRead: ContextHygieneMutationAfterReadReportEntry[] = [];
|
|
446
|
+
const staleCandidates: ContextHygieneStaleCandidateReportEntry[] = [];
|
|
447
|
+
|
|
448
|
+
for (const resourceKey of sortResourceKeys(mutationEventsByResource.keys())) {
|
|
449
|
+
const reads = readEventsByResource.get(resourceKey) ?? [];
|
|
450
|
+
const mutations = mutationEventsByResource.get(resourceKey) ?? [];
|
|
451
|
+
for (const mutation of mutations) {
|
|
452
|
+
const priorReads = reads.filter((read) => read.id < mutation.id);
|
|
453
|
+
const priorReadIds = priorReads.map((read) => read.id);
|
|
454
|
+
if (priorReadIds.length === 0) continue;
|
|
455
|
+
mutationAfterRead.push({ resourceKey, readEventIds: priorReadIds, mutationEventId: mutation.id });
|
|
456
|
+
staleCandidates.push({
|
|
457
|
+
resourceKey,
|
|
458
|
+
staleEventIds: priorReadIds,
|
|
459
|
+
mutationEventId: mutation.id,
|
|
460
|
+
reason: "mutation-after-read",
|
|
461
|
+
staleResults: priorReads.map((read) => buildStaleContextRecord({
|
|
462
|
+
originalTool: read.tool,
|
|
463
|
+
originalEventId: read.id,
|
|
464
|
+
originalResultId: read.resultId,
|
|
465
|
+
staleResourceKeys: [resourceKey],
|
|
466
|
+
invalidatingMutationEventId: mutation.id,
|
|
467
|
+
invalidatingMutationResultId: mutation.resultId,
|
|
468
|
+
reason: "mutation-after-read",
|
|
469
|
+
rehydrate: read.rehydrate,
|
|
470
|
+
})),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
eventCount: this.events.length,
|
|
477
|
+
resourceCount: eventsByResource.size,
|
|
478
|
+
readReuse,
|
|
479
|
+
mutationAfterRead,
|
|
480
|
+
staleCandidates,
|
|
481
|
+
churn: {
|
|
482
|
+
byClassification,
|
|
483
|
+
byTool: Object.fromEntries(Object.entries(byTool).sort(([left], [right]) => compareStable(left, right))),
|
|
484
|
+
uniqueResourcesSeen: eventsByResource.size,
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function createContextHygieneTracker(options: CreateContextHygieneTrackerOptions = {}): ContextHygieneTracker {
|
|
491
|
+
return new DefaultContextHygieneTracker(options);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let globalContextHygieneTracker = createContextHygieneTracker();
|
|
495
|
+
|
|
496
|
+
export function resetContextHygieneTracker(options: CreateContextHygieneTrackerOptions = {}): ContextHygieneTracker {
|
|
497
|
+
globalContextHygieneTracker = createContextHygieneTracker(options);
|
|
498
|
+
return globalContextHygieneTracker;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function getContextHygieneTracker(): ContextHygieneTracker {
|
|
502
|
+
return globalContextHygieneTracker;
|
|
503
|
+
}
|