@inixiative/foundry-core 0.2.0 → 0.4.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/package.json +1 -6
- package/src/action-prompt.ts +47 -28
- package/src/adapters/file-memory.ts +314 -122
- package/src/adapters/http-memory.ts +19 -24
- package/src/adapters/index.ts +4 -4
- package/src/adapters/markdown-docs.ts +15 -15
- package/src/adapters/sqlite-memory.ts +29 -27
- package/src/base-agent.ts +5 -7
- package/src/cache-lifecycle.ts +14 -14
- package/src/capability.ts +76 -83
- package/src/clarifier.ts +46 -0
- package/src/classifier.ts +6 -9
- package/src/context-layer.ts +125 -78
- package/src/context-stack.ts +15 -14
- package/src/credentials.ts +4 -1
- package/src/decider.ts +7 -8
- package/src/delivery-evidence.ts +206 -55
- package/src/event-stream.ts +34 -20
- package/src/executor.ts +81 -43
- package/src/harness.ts +225 -126
- package/src/hooks.ts +26 -26
- package/src/hydrator.ts +6 -8
- package/src/id.ts +3 -3
- package/src/index.ts +252 -218
- package/src/intervention.ts +21 -12
- package/src/messages.ts +73 -62
- package/src/middleware.ts +9 -19
- package/src/model-registry.ts +1645 -0
- package/src/native-evidence.ts +124 -30
- package/src/permission-middleware.ts +16 -28
- package/src/retry.ts +6 -6
- package/src/router.ts +5 -5
- package/src/signal.ts +16 -11
- package/src/thread.ts +138 -61
- package/src/token-counts.ts +9 -1
- package/src/token-tracker.ts +43 -34
- package/src/tools.ts +138 -95
- package/src/trace.ts +44 -23
- package/src/types.ts +33 -24
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join,
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, relative, resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
type ContextSource,
|
|
5
|
+
computeHash,
|
|
6
|
+
copyMessageIdentity,
|
|
7
|
+
type LogicalMessageIdentity,
|
|
8
|
+
REQUIRED_CONTEXT_BLOCKED,
|
|
9
|
+
type SourceLoadHint,
|
|
10
|
+
type SourceSelectionReport,
|
|
11
|
+
} from '../context-layer';
|
|
12
|
+
import type { ContextRef, HydrationAdapter } from '../hydrator';
|
|
13
|
+
import { normalizeScope, type OwnershipScope } from '../scope';
|
|
14
|
+
import type { Signal } from '../signal';
|
|
15
|
+
import type { MemoryEntry, MemoryVisibility } from '../tools';
|
|
16
|
+
|
|
17
|
+
export type { MemoryEntry } from '../tools';
|
|
10
18
|
|
|
11
19
|
// ---------------------------------------------------------------------------
|
|
12
20
|
// Bounded selection — the retained log is complete; the injected text is not
|
|
@@ -36,7 +44,7 @@ export interface MemorySelectionPolicy {
|
|
|
36
44
|
* conflict that the executor turns into an explicit error before any
|
|
37
45
|
* provider call. Neither mode ever names a retrieval tool that may not exist.
|
|
38
46
|
*/
|
|
39
|
-
readonly oversizedPinned:
|
|
47
|
+
readonly oversizedPinned: 'inject' | 'block';
|
|
40
48
|
/** Most records retrieved for relevance to the current message. */
|
|
41
49
|
readonly retrievalLimit: number;
|
|
42
50
|
/** Distinct focus terms an audit record must match to be retrieved. Other kinds need one. */
|
|
@@ -45,22 +53,43 @@ export interface MemorySelectionPolicy {
|
|
|
45
53
|
|
|
46
54
|
export const DEFAULT_MEMORY_SELECTION: MemorySelectionPolicy = Object.freeze({
|
|
47
55
|
budgetChars: 6000,
|
|
48
|
-
pinnedKinds: Object.freeze([
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
pinnedKinds: Object.freeze([
|
|
57
|
+
'instruction',
|
|
58
|
+
'pin',
|
|
59
|
+
'pinned',
|
|
60
|
+
'convention',
|
|
61
|
+
'correction',
|
|
62
|
+
'decision',
|
|
63
|
+
'requirement',
|
|
64
|
+
]),
|
|
65
|
+
auditOnlyKinds: Object.freeze([
|
|
66
|
+
'dispatch',
|
|
67
|
+
'classification',
|
|
68
|
+
'context_loaded',
|
|
69
|
+
'session_compacted',
|
|
70
|
+
'auxiliary_session_compacted',
|
|
71
|
+
'info',
|
|
72
|
+
'domain_learning',
|
|
73
|
+
'tool_observation',
|
|
74
|
+
'guard_finding',
|
|
75
|
+
]),
|
|
51
76
|
recentLimit: 8,
|
|
52
77
|
maxEntryChars: 1200,
|
|
53
78
|
pinnedHardCapChars: 24_000,
|
|
54
|
-
oversizedPinned:
|
|
79
|
+
oversizedPinned: 'inject',
|
|
55
80
|
retrievalLimit: 6,
|
|
56
81
|
auditRelevanceTerms: 2,
|
|
57
82
|
});
|
|
58
83
|
|
|
59
84
|
const NUMERIC_BOUNDS: Record<string, readonly [number, number]> = {
|
|
60
|
-
budgetChars: [200, 5_000_000],
|
|
61
|
-
|
|
85
|
+
budgetChars: [200, 5_000_000],
|
|
86
|
+
maxEntryChars: [50, 5_000_000],
|
|
87
|
+
pinnedHardCapChars: [100, 50_000_000],
|
|
88
|
+
recentLimit: [0, 10_000],
|
|
89
|
+
retrievalLimit: [0, 10_000],
|
|
90
|
+
auditRelevanceTerms: [1, 48],
|
|
62
91
|
};
|
|
63
|
-
const KIND_LISTS = new Set([
|
|
92
|
+
const KIND_LISTS = new Set(['pinnedKinds', 'auditOnlyKinds']);
|
|
64
93
|
|
|
65
94
|
/**
|
|
66
95
|
* Runtime validation for a selection policy override coming from persisted or
|
|
@@ -68,8 +97,10 @@ const KIND_LISTS = new Set(["pinnedKinds", "auditOnlyKinds"]);
|
|
|
68
97
|
* descriptive Error naming the offending field; returns a defensive copy.
|
|
69
98
|
*/
|
|
70
99
|
export function validateMemorySelection(input: unknown): Partial<MemorySelectionPolicy> {
|
|
71
|
-
if (input === null || typeof input !==
|
|
72
|
-
throw new Error(
|
|
100
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`memory selection policy must be an object, got ${input === null ? 'null' : Array.isArray(input) ? 'array' : typeof input}`,
|
|
103
|
+
);
|
|
73
104
|
}
|
|
74
105
|
const out: Record<string, unknown> = {};
|
|
75
106
|
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
|
@@ -77,40 +108,98 @@ export function validateMemorySelection(input: unknown): Partial<MemorySelection
|
|
|
77
108
|
// Own-property lookup: inherited names such as constructor or __proto__ are not policy fields.
|
|
78
109
|
const bounds = Object.hasOwn(NUMERIC_BOUNDS, key) ? NUMERIC_BOUNDS[key] : undefined;
|
|
79
110
|
if (bounds) {
|
|
80
|
-
if (
|
|
81
|
-
|
|
111
|
+
if (
|
|
112
|
+
typeof value !== 'number' ||
|
|
113
|
+
!Number.isInteger(value) ||
|
|
114
|
+
value < bounds[0] ||
|
|
115
|
+
value > bounds[1]
|
|
116
|
+
) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`memory selection policy ${key} must be an integer between ${bounds[0]} and ${bounds[1]}, got ${JSON.stringify(value)}`,
|
|
119
|
+
);
|
|
82
120
|
}
|
|
83
121
|
out[key] = value;
|
|
84
122
|
} else if (KIND_LISTS.has(key)) {
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
123
|
+
if (
|
|
124
|
+
!Array.isArray(value) ||
|
|
125
|
+
value.length > 256 ||
|
|
126
|
+
value.some((k) => typeof k !== 'string' || !k.trim() || k.length > 64) ||
|
|
127
|
+
new Set(value).size !== value.length
|
|
128
|
+
) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`memory selection policy ${key} must be an array of unique non-empty strings, got ${JSON.stringify(value)}`,
|
|
131
|
+
);
|
|
88
132
|
}
|
|
89
133
|
out[key] = [...value];
|
|
90
|
-
} else if (key ===
|
|
91
|
-
if (value !==
|
|
134
|
+
} else if (key === 'oversizedPinned') {
|
|
135
|
+
if (value !== 'inject' && value !== 'block')
|
|
136
|
+
throw new Error(
|
|
137
|
+
`memory selection policy oversizedPinned must be "inject" or "block", got ${JSON.stringify(value)}`,
|
|
138
|
+
);
|
|
92
139
|
out[key] = value;
|
|
93
140
|
} else {
|
|
94
141
|
throw new Error(`memory selection policy has unknown field ${JSON.stringify(key)}`);
|
|
95
142
|
}
|
|
96
143
|
}
|
|
97
|
-
if (
|
|
98
|
-
|
|
144
|
+
if (
|
|
145
|
+
typeof out.maxEntryChars === 'number' &&
|
|
146
|
+
typeof out.budgetChars === 'number' &&
|
|
147
|
+
out.maxEntryChars > out.budgetChars
|
|
148
|
+
) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`memory selection policy maxEntryChars (${out.maxEntryChars}) cannot exceed budgetChars (${out.budgetChars})`,
|
|
151
|
+
);
|
|
99
152
|
}
|
|
100
153
|
return out as Partial<MemorySelectionPolicy>;
|
|
101
154
|
}
|
|
102
155
|
|
|
103
156
|
export type MemorySelectionReport = SourceSelectionReport;
|
|
104
157
|
|
|
105
|
-
const STOPWORDS = new Set([
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
const STOPWORDS = new Set([
|
|
159
|
+
'that',
|
|
160
|
+
'this',
|
|
161
|
+
'with',
|
|
162
|
+
'from',
|
|
163
|
+
'have',
|
|
164
|
+
'what',
|
|
165
|
+
'when',
|
|
166
|
+
'where',
|
|
167
|
+
'which',
|
|
168
|
+
'will',
|
|
169
|
+
'would',
|
|
170
|
+
'should',
|
|
171
|
+
'could',
|
|
172
|
+
'about',
|
|
173
|
+
'there',
|
|
174
|
+
'their',
|
|
175
|
+
'these',
|
|
176
|
+
'those',
|
|
177
|
+
'does',
|
|
178
|
+
'into',
|
|
179
|
+
'only',
|
|
180
|
+
'also',
|
|
181
|
+
'than',
|
|
182
|
+
'then',
|
|
183
|
+
'them',
|
|
184
|
+
'they',
|
|
185
|
+
'your',
|
|
186
|
+
'just',
|
|
187
|
+
'like',
|
|
188
|
+
'make',
|
|
189
|
+
'made',
|
|
190
|
+
'over',
|
|
191
|
+
'under',
|
|
192
|
+
'after',
|
|
193
|
+
'before',
|
|
194
|
+
'while',
|
|
195
|
+
'please',
|
|
196
|
+
]);
|
|
108
197
|
|
|
109
198
|
/** Deterministic terms from the current message: lowercase words of four or more letters, no stopwords. */
|
|
110
199
|
export function focusTerms(focus: string, max = 48): string[] {
|
|
111
200
|
const seen = new Set<string>();
|
|
112
201
|
for (const raw of focus.toLowerCase().split(/[^a-z0-9_.-]+/)) {
|
|
113
|
-
const term = raw.replace(/^[._-]+|[._-]+$/g,
|
|
202
|
+
const term = raw.replace(/^[._-]+|[._-]+$/g, '');
|
|
114
203
|
if (term.length < 4 || STOPWORDS.has(term) || seen.has(term)) continue;
|
|
115
204
|
seen.add(term);
|
|
116
205
|
if (seen.size >= max) break;
|
|
@@ -124,8 +213,10 @@ type Excerpt = { text: string; truncated: boolean; ranges?: CharRange[] };
|
|
|
124
213
|
/** Render the named character ranges of a record; every excerpt states exactly which parts of the record it carries. */
|
|
125
214
|
function renderRanges(entry: MemoryEntry, ranges: CharRange[]): string {
|
|
126
215
|
const total = entry.content.length;
|
|
127
|
-
const pieces = ranges.map(
|
|
128
|
-
|
|
216
|
+
const pieces = ranges.map(
|
|
217
|
+
([a, b]) => `${a > 0 ? '… ' : ''}${entry.content.slice(a, b)}${b < total ? ' …' : ''}`,
|
|
218
|
+
);
|
|
219
|
+
return `${pieces.join(' ')} [excerpt: chars ${ranges.map(([a, b]) => `${a}-${b}`).join(', ')} of ${total}; full record ${entry.id} retained in the owned log]`;
|
|
129
220
|
}
|
|
130
221
|
|
|
131
222
|
/** Leading excerpt for records selected by recency, where no particular passage is the point. */
|
|
@@ -145,7 +236,10 @@ function matchExcerpt(entry: MemoryEntry, matched: readonly string[], max: numbe
|
|
|
145
236
|
const total = entry.content.length;
|
|
146
237
|
if (total <= max) return { text: entry.content, truncated: false };
|
|
147
238
|
const lower = entry.content.toLowerCase();
|
|
148
|
-
const hits = matched
|
|
239
|
+
const hits = matched
|
|
240
|
+
.map((t) => lower.indexOf(t))
|
|
241
|
+
.filter((i) => i >= 0)
|
|
242
|
+
.sort((a, b) => a - b);
|
|
149
243
|
if (!hits.length) return prefixExcerpt(entry, max);
|
|
150
244
|
const width = Math.max(1, Math.floor(max / hits.length));
|
|
151
245
|
const ranges: CharRange[] = [];
|
|
@@ -160,8 +254,10 @@ function matchExcerpt(entry: MemoryEntry, matched: readonly string[], max: numbe
|
|
|
160
254
|
}
|
|
161
255
|
|
|
162
256
|
function line(entry: MemoryEntry, text: string, note?: string): string {
|
|
163
|
-
const when = Number.isFinite(entry.timestamp)
|
|
164
|
-
|
|
257
|
+
const when = Number.isFinite(entry.timestamp)
|
|
258
|
+
? new Date(entry.timestamp).toISOString()
|
|
259
|
+
: 'unknown time';
|
|
260
|
+
return `[${entry.kind}] ${entry.id} (${when}${note ? `; ${note}` : ''}): ${text}`;
|
|
165
261
|
}
|
|
166
262
|
|
|
167
263
|
/**
|
|
@@ -177,20 +273,35 @@ export function selectMemory(
|
|
|
177
273
|
currentMessage?: LogicalMessageIdentity,
|
|
178
274
|
): { text: string; report: MemorySelectionReport } {
|
|
179
275
|
const identity = copyMessageIdentity(currentMessage);
|
|
180
|
-
const entries = [...visible].sort(
|
|
276
|
+
const entries = [...visible].sort(
|
|
277
|
+
(a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id),
|
|
278
|
+
);
|
|
181
279
|
const pinned = new Set(policy.pinnedKinds);
|
|
182
280
|
const audit = new Set(policy.auditOnlyKinds);
|
|
183
|
-
const selected: MemorySelectionReport[
|
|
184
|
-
const omitted = new Map<string, MemorySelectionReport[
|
|
185
|
-
const conflicts: MemorySelectionReport[
|
|
281
|
+
const selected: MemorySelectionReport['selected'][number][] = [];
|
|
282
|
+
const omitted = new Map<string, MemorySelectionReport['omitted'][number]>();
|
|
283
|
+
const conflicts: MemorySelectionReport['conflicts'][number][] = [];
|
|
186
284
|
const taken = new Set<string>();
|
|
187
285
|
let used = 0;
|
|
188
|
-
const omit = (e: MemoryEntry, reason: string) =>
|
|
189
|
-
|
|
286
|
+
const omit = (e: MemoryEntry, reason: string) =>
|
|
287
|
+
omitted.set(e.id, { id: e.id, reason, chars: e.content.length, kind: e.kind });
|
|
288
|
+
const select = (
|
|
289
|
+
e: MemoryEntry,
|
|
290
|
+
reason: string,
|
|
291
|
+
rendered: string,
|
|
292
|
+
extra: Partial<MemorySelectionReport['selected'][number]> = {},
|
|
293
|
+
) => {
|
|
190
294
|
used += rendered.length;
|
|
191
295
|
taken.add(e.id);
|
|
192
296
|
omitted.delete(e.id);
|
|
193
|
-
selected.push({
|
|
297
|
+
selected.push({
|
|
298
|
+
id: e.id,
|
|
299
|
+
reason,
|
|
300
|
+
chars: rendered.length,
|
|
301
|
+
kind: e.kind,
|
|
302
|
+
timestamp: e.timestamp,
|
|
303
|
+
...extra,
|
|
304
|
+
});
|
|
194
305
|
};
|
|
195
306
|
const withExcerpt = (x: Excerpt) => (x.truncated ? { truncated: true, ranges: x.ranges } : {});
|
|
196
307
|
|
|
@@ -204,10 +315,13 @@ export function selectMemory(
|
|
|
204
315
|
if (!pinned.has(entry.kind)) continue;
|
|
205
316
|
if (entry.content.length > policy.pinnedHardCapChars) {
|
|
206
317
|
oversized.push(entry.id);
|
|
207
|
-
if (policy.oversizedPinned ===
|
|
318
|
+
if (policy.oversizedPinned === 'block') {
|
|
208
319
|
// A mandatory record that cannot be carried is a reason to stop, not a
|
|
209
320
|
// note to the model. The executor refuses provider execution on this conflict.
|
|
210
|
-
const notice = line(
|
|
321
|
+
const notice = line(
|
|
322
|
+
entry,
|
|
323
|
+
`BLOCKED: ${entry.content.length} chars exceeds the ${policy.pinnedHardCapChars}-char pinned cap and no scoped retrieval is available. Execution must not proceed without this record.`,
|
|
324
|
+
);
|
|
211
325
|
used += notice.length;
|
|
212
326
|
pinnedLines.push(notice);
|
|
213
327
|
taken.add(entry.id);
|
|
@@ -219,32 +333,64 @@ export function selectMemory(
|
|
|
219
333
|
const rendered = line(entry, entry.content);
|
|
220
334
|
if (used + rendered.length > policy.budgetChars) overBudget.push(entry.id);
|
|
221
335
|
pinnedLines.push(rendered);
|
|
222
|
-
select(entry,
|
|
336
|
+
select(entry, 'pinned', rendered);
|
|
223
337
|
}
|
|
224
338
|
if (overBudget.length) {
|
|
225
|
-
conflicts.push({
|
|
226
|
-
|
|
339
|
+
conflicts.push({
|
|
340
|
+
kind: 'pinned-over-budget',
|
|
341
|
+
ids: overBudget,
|
|
342
|
+
detail: `Pinned records exceed the ${policy.budgetChars}-char budget; they are injected in full anyway and the overrun is visible here.`,
|
|
343
|
+
});
|
|
227
344
|
}
|
|
228
345
|
if (oversized.length) {
|
|
229
|
-
conflicts.push(
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
346
|
+
conflicts.push(
|
|
347
|
+
policy.oversizedPinned === 'block'
|
|
348
|
+
? {
|
|
349
|
+
kind: REQUIRED_CONTEXT_BLOCKED,
|
|
350
|
+
ids: oversized,
|
|
351
|
+
detail: `Pinned records larger than the ${policy.pinnedHardCapChars}-char cap cannot be carried and no scoped retrieval is available; provider execution is refused until an operator raises the cap or supplies retrieval.`,
|
|
352
|
+
}
|
|
353
|
+
: {
|
|
354
|
+
kind: 'pinned-oversized',
|
|
355
|
+
ids: oversized,
|
|
356
|
+
detail: `Pinned records larger than the ${policy.pinnedHardCapChars}-char cap were injected in full because no scoped retrieval is available; the overrun is visible here.`,
|
|
357
|
+
},
|
|
358
|
+
);
|
|
234
359
|
}
|
|
235
360
|
|
|
236
361
|
// The current request's own audit is not independent evidence for it.
|
|
237
362
|
// Pinning wins above; owner and explicit message identity must both match.
|
|
238
363
|
// Text matches, nested IDs and legacy records without an owner prove nothing.
|
|
239
364
|
for (const entry of entries) {
|
|
240
|
-
if (
|
|
365
|
+
if (
|
|
366
|
+
!identity ||
|
|
367
|
+
taken.has(entry.id) ||
|
|
368
|
+
!audit.has(entry.kind) ||
|
|
369
|
+
!sameOwner(entry.owner, identity)
|
|
370
|
+
)
|
|
371
|
+
continue;
|
|
241
372
|
let content: unknown;
|
|
242
|
-
try {
|
|
243
|
-
|
|
244
|
-
|
|
373
|
+
try {
|
|
374
|
+
content = JSON.parse(entry.content);
|
|
375
|
+
} catch {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
!content ||
|
|
380
|
+
typeof content !== 'object' ||
|
|
381
|
+
Array.isArray(content) ||
|
|
382
|
+
!('messageId' in content) ||
|
|
383
|
+
content.messageId !== identity.messageId
|
|
384
|
+
)
|
|
385
|
+
continue;
|
|
245
386
|
taken.add(entry.id);
|
|
246
|
-
omitted.set(entry.id, {
|
|
247
|
-
|
|
387
|
+
omitted.set(entry.id, {
|
|
388
|
+
id: entry.id,
|
|
389
|
+
kind: entry.kind,
|
|
390
|
+
chars: entry.content.length,
|
|
391
|
+
reason: 'current-message-audit',
|
|
392
|
+
excludedFor: identity,
|
|
393
|
+
});
|
|
248
394
|
}
|
|
249
395
|
|
|
250
396
|
// 2. Relevant to this message: retrieved across every visible kind, audit kinds need a stronger match.
|
|
@@ -258,15 +404,28 @@ export function selectMemory(
|
|
|
258
404
|
const matched = terms.filter((t) => lower.includes(t));
|
|
259
405
|
return { e, matched };
|
|
260
406
|
})
|
|
261
|
-
.filter(
|
|
262
|
-
|
|
407
|
+
.filter(
|
|
408
|
+
({ e, matched }) => matched.length >= (audit.has(e.kind) ? policy.auditRelevanceTerms : 1),
|
|
409
|
+
)
|
|
410
|
+
.sort(
|
|
411
|
+
(a, b) =>
|
|
412
|
+
b.matched.length - a.matched.length ||
|
|
413
|
+
b.e.timestamp - a.e.timestamp ||
|
|
414
|
+
a.e.id.localeCompare(b.e.id),
|
|
415
|
+
);
|
|
263
416
|
for (const { e, matched } of scored) {
|
|
264
|
-
if (relevantLines.length >= policy.retrievalLimit) {
|
|
417
|
+
if (relevantLines.length >= policy.retrievalLimit) {
|
|
418
|
+
omit(e, 'retrieval-limit');
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
265
421
|
const x = matchExcerpt(e, matched, policy.maxEntryChars);
|
|
266
|
-
const rendered = line(e, x.text, `matched: ${matched.join(
|
|
267
|
-
if (used + rendered.length > policy.budgetChars) {
|
|
422
|
+
const rendered = line(e, x.text, `matched: ${matched.join(', ')}`);
|
|
423
|
+
if (used + rendered.length > policy.budgetChars) {
|
|
424
|
+
omit(e, 'budget');
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
268
427
|
relevantLines.push(rendered);
|
|
269
|
-
select(e,
|
|
428
|
+
select(e, 'relevant', rendered, { matched, ...withExcerpt(x) });
|
|
270
429
|
}
|
|
271
430
|
}
|
|
272
431
|
|
|
@@ -276,43 +435,69 @@ export function selectMemory(
|
|
|
276
435
|
let recentSeen = 0;
|
|
277
436
|
for (const entry of [...entries].reverse()) {
|
|
278
437
|
if (taken.has(entry.id) || pinned.has(entry.kind)) continue;
|
|
279
|
-
if (audit.has(entry.kind)) {
|
|
438
|
+
if (audit.has(entry.kind)) {
|
|
439
|
+
omit(entry, 'audit-only');
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
280
442
|
recentSeen += 1;
|
|
281
|
-
if (recentSeen > policy.recentLimit) {
|
|
443
|
+
if (recentSeen > policy.recentLimit) {
|
|
444
|
+
omit(entry, 'recent-limit');
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
282
447
|
const x = prefixExcerpt(entry, policy.maxEntryChars);
|
|
283
448
|
const rendered = line(entry, x.text);
|
|
284
|
-
if (used + rendered.length > policy.budgetChars) {
|
|
449
|
+
if (used + rendered.length > policy.budgetChars) {
|
|
450
|
+
omit(entry, 'budget');
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
285
453
|
recentLines.push(rendered);
|
|
286
|
-
select(entry,
|
|
454
|
+
select(entry, 'recent', rendered, withExcerpt(x));
|
|
287
455
|
}
|
|
288
456
|
|
|
289
457
|
// 4. Omission summary in the injected text itself, so the model and the operator see the same account.
|
|
290
458
|
const omittedList = [...omitted.values()];
|
|
291
|
-
const auditOmitted = omittedList.filter(
|
|
292
|
-
|
|
459
|
+
const auditOmitted = omittedList.filter(
|
|
460
|
+
(o) => o.reason === 'audit-only' || o.reason === 'current-message-audit',
|
|
461
|
+
);
|
|
462
|
+
const currentAuditCount = omittedList.filter((o) => o.reason === 'current-message-audit').length;
|
|
293
463
|
const otherOmitted = omittedList.length - auditOmitted.length;
|
|
294
464
|
const byKind = new Map<string, number>();
|
|
295
|
-
for (const o of auditOmitted)
|
|
296
|
-
|
|
465
|
+
for (const o of auditOmitted)
|
|
466
|
+
byKind.set(o.kind ?? 'unknown', (byKind.get(o.kind ?? 'unknown') ?? 0) + 1);
|
|
467
|
+
const kinds = [...byKind.entries()]
|
|
468
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
469
|
+
.map(([k, n]) => `${k} ${n}`)
|
|
470
|
+
.join(', ');
|
|
297
471
|
const retainedChars = entries.reduce((sum, e) => sum + e.content.length, 0);
|
|
298
472
|
const summary = [
|
|
299
|
-
`${auditOmitted.length} audit records (${auditOmitted.reduce((s, o) => s + o.chars, 0)} chars${kinds ? `; ${kinds}` :
|
|
473
|
+
`${auditOmitted.length} audit records (${auditOmitted.reduce((s, o) => s + o.chars, 0)} chars${kinds ? `; ${kinds}` : ''})` +
|
|
300
474
|
` and ${otherOmitted} other captures were not injected.`,
|
|
301
475
|
`The complete owned log (${entries.length} records, ${retainedChars} chars) is retained unchanged; records not shown here were not supplied to this turn.`,
|
|
302
|
-
...(currentAuditCount
|
|
303
|
-
|
|
304
|
-
|
|
476
|
+
...(currentAuditCount
|
|
477
|
+
? [
|
|
478
|
+
`${currentAuditCount} records were excluded as the current message's own audit (matched logical message, thread and project identity).`,
|
|
479
|
+
]
|
|
480
|
+
: []),
|
|
481
|
+
`Selection budget ${policy.budgetChars} chars, used ${used}${used > policy.budgetChars ? ' (exceeded by pinned records)' : ''}.`,
|
|
482
|
+
].join(' ');
|
|
305
483
|
|
|
306
484
|
const sections: string[] = [];
|
|
307
|
-
if (pinnedLines.length)
|
|
308
|
-
|
|
309
|
-
if (
|
|
485
|
+
if (pinnedLines.length)
|
|
486
|
+
sections.push(`## Pinned (${pinnedLines.length})\n${pinnedLines.join('\n')}`);
|
|
487
|
+
if (relevantLines.length)
|
|
488
|
+
sections.push(
|
|
489
|
+
`## Relevant to this message (${relevantLines.length})\n${relevantLines.join('\n')}`,
|
|
490
|
+
);
|
|
491
|
+
if (recentLines.length)
|
|
492
|
+
sections.push(`## Recent captures (${recentLines.length})\n${recentLines.join('\n')}`);
|
|
310
493
|
sections.push(`## Not injected\n${summary}`);
|
|
311
494
|
|
|
312
495
|
return {
|
|
313
|
-
text: sections.join(
|
|
496
|
+
text: sections.join('\n\n'),
|
|
314
497
|
report: {
|
|
315
|
-
selected,
|
|
498
|
+
selected,
|
|
499
|
+
omitted: omittedList,
|
|
500
|
+
considered: entries.length,
|
|
316
501
|
retained: { count: entries.length, chars: retainedChars },
|
|
317
502
|
budget: { chars: policy.budgetChars, used, exceeded: used > policy.budgetChars },
|
|
318
503
|
conflicts,
|
|
@@ -354,11 +539,11 @@ export interface MemorySourceOpts {
|
|
|
354
539
|
/** Whether one entry is visible to a reader. */
|
|
355
540
|
export function entryVisibleTo(entry: MemoryEntry, scope: MemoryReadScope): boolean {
|
|
356
541
|
switch (entry.visibility) {
|
|
357
|
-
case
|
|
542
|
+
case 'global':
|
|
358
543
|
return true;
|
|
359
|
-
case
|
|
544
|
+
case 'project':
|
|
360
545
|
return !!scope.projectId && entry.owner?.projectId === scope.projectId;
|
|
361
|
-
case
|
|
546
|
+
case 'thread':
|
|
362
547
|
// A thread id alone is not identity: the owner's project must match too,
|
|
363
548
|
// so a same-named thread in another project cannot read it.
|
|
364
549
|
return (
|
|
@@ -387,7 +572,7 @@ function formatEntries(entries: MemoryEntry[]): string {
|
|
|
387
572
|
return entries
|
|
388
573
|
.sort((a, b) => b.timestamp - a.timestamp)
|
|
389
574
|
.map((e) => `[${e.kind}] ${e.id}: ${e.content}`)
|
|
390
|
-
.join(
|
|
575
|
+
.join('\n');
|
|
391
576
|
}
|
|
392
577
|
|
|
393
578
|
/**
|
|
@@ -417,10 +602,10 @@ export class FileMemory {
|
|
|
417
602
|
|
|
418
603
|
/** Resolve an entry path safely — prevents path traversal. */
|
|
419
604
|
private _safePath(id: string): string {
|
|
420
|
-
const safe = id.replace(/[
|
|
605
|
+
const safe = id.replace(/[/\\.]+/g, '_');
|
|
421
606
|
const path = join(this.dir, `${safe}.json`);
|
|
422
607
|
const rel = relative(this.dir, resolve(path));
|
|
423
|
-
if (rel.startsWith(
|
|
608
|
+
if (rel.startsWith('..') || rel.includes('/..')) {
|
|
424
609
|
throw new Error(`Invalid entry id: ${id}`);
|
|
425
610
|
}
|
|
426
611
|
return path;
|
|
@@ -428,7 +613,7 @@ export class FileMemory {
|
|
|
428
613
|
|
|
429
614
|
/** Load all entries from disk. */
|
|
430
615
|
async load(): Promise<void> {
|
|
431
|
-
const files = new Bun.Glob(
|
|
616
|
+
const files = new Bun.Glob('*.json').scanSync(this.dir);
|
|
432
617
|
for (const file of files) {
|
|
433
618
|
const path = join(this.dir, file);
|
|
434
619
|
const data = await Bun.file(path).json();
|
|
@@ -456,10 +641,10 @@ export class FileMemory {
|
|
|
456
641
|
async publish(id: string, visibility: MemoryVisibility): Promise<MemoryEntry> {
|
|
457
642
|
const entry = this._entries.get(id);
|
|
458
643
|
if (!entry) throw new Error(`Memory entry not found: ${id}`);
|
|
459
|
-
if (visibility ===
|
|
644
|
+
if (visibility === 'project' && !entry.owner?.projectId) {
|
|
460
645
|
throw new Error(`Cannot publish "${id}" to a project: it has no project owner`);
|
|
461
646
|
}
|
|
462
|
-
if (visibility ===
|
|
647
|
+
if (visibility === 'thread' && !entry.owner?.threadId) {
|
|
463
648
|
throw new Error(`Cannot restrict "${id}" to a thread: it has no thread owner`);
|
|
464
649
|
}
|
|
465
650
|
const updated = { ...entry, visibility };
|
|
@@ -481,9 +666,7 @@ export class FileMemory {
|
|
|
481
666
|
/** Search entries by content substring. */
|
|
482
667
|
search(query: string): MemoryEntry[] {
|
|
483
668
|
const lower = query.toLowerCase();
|
|
484
|
-
return [...this._entries.values()].filter((e) =>
|
|
485
|
-
e.content.toLowerCase().includes(lower)
|
|
486
|
-
);
|
|
669
|
+
return [...this._entries.values()].filter((e) => e.content.toLowerCase().includes(lower));
|
|
487
670
|
}
|
|
488
671
|
|
|
489
672
|
/** Delete an entry. Removes from disk first to prevent desync. */
|
|
@@ -491,7 +674,7 @@ export class FileMemory {
|
|
|
491
674
|
if (!this._entries.has(id)) return false;
|
|
492
675
|
const path = this._safePath(id);
|
|
493
676
|
try {
|
|
494
|
-
const { unlinkSync } = await import(
|
|
677
|
+
const { unlinkSync } = await import('node:fs');
|
|
495
678
|
unlinkSync(path);
|
|
496
679
|
} catch (err) {
|
|
497
680
|
console.warn(`[FileMemory] unlink failed for "${id}":`, (err as Error).message);
|
|
@@ -503,7 +686,7 @@ export class FileMemory {
|
|
|
503
686
|
/** Get all entries as a formatted string (for use as context). */
|
|
504
687
|
toContext(): string {
|
|
505
688
|
const entries = [...this._entries.values()];
|
|
506
|
-
if (entries.length === 0) return
|
|
689
|
+
if (entries.length === 0) return '(no entries)';
|
|
507
690
|
return formatEntries(entries);
|
|
508
691
|
}
|
|
509
692
|
|
|
@@ -518,20 +701,21 @@ export class FileMemory {
|
|
|
518
701
|
* source's declared scope for the owning thread/project.
|
|
519
702
|
*/
|
|
520
703
|
asSource(id: string, opts?: string | MemorySourceOpts): ContextSource {
|
|
521
|
-
const options: MemorySourceOpts = typeof opts ===
|
|
704
|
+
const options: MemorySourceOpts = typeof opts === 'string' ? { kind: opts } : (opts ?? {});
|
|
522
705
|
const mem = this;
|
|
523
|
-
const policy: MemorySelectionPolicy | undefined =
|
|
524
|
-
|
|
525
|
-
|
|
706
|
+
const policy: MemorySelectionPolicy | undefined =
|
|
707
|
+
options.selection === undefined || options.selection === false
|
|
708
|
+
? undefined
|
|
709
|
+
: { ...DEFAULT_MEMORY_SELECTION, ...validateMemorySelection(options.selection) };
|
|
526
710
|
|
|
527
711
|
const make = (bound: OwnershipScope): ContextSource => {
|
|
528
712
|
let lastReport: MemorySelectionReport | undefined;
|
|
529
713
|
const readScope = (): MemoryReadScope => ({
|
|
530
714
|
// Resolve the bound scope at load time: a thread's project may be
|
|
531
715
|
// assigned after creation, and every refresh must honor it.
|
|
532
|
-
...(options.scope ===
|
|
716
|
+
...(options.scope === 'global'
|
|
533
717
|
? {}
|
|
534
|
-
: options.scope ===
|
|
718
|
+
: options.scope === 'project'
|
|
535
719
|
? { projectId: bound.projectId }
|
|
536
720
|
: { threadId: bound.threadId, projectId: bound.projectId }),
|
|
537
721
|
includeUnowned: options.includeUnowned,
|
|
@@ -542,11 +726,14 @@ export class FileMemory {
|
|
|
542
726
|
await mem.ensureLoaded();
|
|
543
727
|
const entries = mem.view(readScope()).all(options.kind);
|
|
544
728
|
if (!policy) {
|
|
545
|
-
if (entries.length === 0) return
|
|
729
|
+
if (entries.length === 0) return '';
|
|
546
730
|
return formatEntries(entries);
|
|
547
731
|
}
|
|
548
732
|
// A load hint cannot impersonate another visible record's owner.
|
|
549
|
-
const identity =
|
|
733
|
+
const identity =
|
|
734
|
+
hint?.currentMessage && sameOwner(bound, hint.currentMessage)
|
|
735
|
+
? hint.currentMessage
|
|
736
|
+
: undefined;
|
|
550
737
|
const { text, report } = selectMemory(entries, policy, hint?.focus, identity);
|
|
551
738
|
lastReport = report;
|
|
552
739
|
return text;
|
|
@@ -566,17 +753,17 @@ export class FileMemory {
|
|
|
566
753
|
asAdapter(): HydrationAdapter {
|
|
567
754
|
const mem = this;
|
|
568
755
|
return {
|
|
569
|
-
system:
|
|
756
|
+
system: 'file-memory',
|
|
570
757
|
async hydrate(ref: ContextRef): Promise<string> {
|
|
571
758
|
await mem.ensureLoaded();
|
|
572
759
|
const entry = mem.get(ref.locator);
|
|
573
|
-
return entry ? entry.content :
|
|
760
|
+
return entry ? entry.content : '';
|
|
574
761
|
},
|
|
575
762
|
async hydrateBatch(refs: ContextRef[]): Promise<string[]> {
|
|
576
763
|
await mem.ensureLoaded();
|
|
577
764
|
return refs.map((r) => {
|
|
578
765
|
const entry = mem.get(r.locator);
|
|
579
|
-
return entry ? entry.content :
|
|
766
|
+
return entry ? entry.content : '';
|
|
580
767
|
});
|
|
581
768
|
},
|
|
582
769
|
};
|
|
@@ -589,26 +776,23 @@ export class FileMemory {
|
|
|
589
776
|
* is written unowned and stays hidden from every scoped read.
|
|
590
777
|
*/
|
|
591
778
|
signalWriter() {
|
|
592
|
-
const mem = this;
|
|
593
779
|
return async (signal: Signal, owner?: OwnershipScope): Promise<void> => {
|
|
594
780
|
const scope = normalizeScope(owner);
|
|
595
781
|
const entry: MemoryEntry = {
|
|
596
782
|
id: signal.id,
|
|
597
783
|
kind: signal.kind,
|
|
598
784
|
content:
|
|
599
|
-
typeof signal.content ===
|
|
600
|
-
? signal.content
|
|
601
|
-
: JSON.stringify(signal.content),
|
|
785
|
+
typeof signal.content === 'string' ? signal.content : JSON.stringify(signal.content),
|
|
602
786
|
source: signal.source,
|
|
603
787
|
timestamp: signal.timestamp,
|
|
604
788
|
meta: { confidence: signal.confidence, refs: signal.refs },
|
|
605
789
|
...(scope.threadId
|
|
606
|
-
? { owner: scope, visibility:
|
|
790
|
+
? { owner: scope, visibility: 'thread' as const }
|
|
607
791
|
: scope.projectId
|
|
608
|
-
? { owner: scope, visibility:
|
|
792
|
+
? { owner: scope, visibility: 'project' as const }
|
|
609
793
|
: {}),
|
|
610
794
|
};
|
|
611
|
-
await
|
|
795
|
+
await this.write(entry);
|
|
612
796
|
};
|
|
613
797
|
}
|
|
614
798
|
}
|
|
@@ -637,11 +821,17 @@ export class MemoryView {
|
|
|
637
821
|
}
|
|
638
822
|
|
|
639
823
|
all(kind?: string): MemoryEntry[] {
|
|
640
|
-
return this._memory
|
|
824
|
+
return this._memory
|
|
825
|
+
.all(kind)
|
|
826
|
+
.filter((e) => this._visible(e))
|
|
827
|
+
.map(copyEntry);
|
|
641
828
|
}
|
|
642
829
|
|
|
643
830
|
search(query: string, limit?: number): MemoryEntry[] {
|
|
644
|
-
const hits = this._memory
|
|
831
|
+
const hits = this._memory
|
|
832
|
+
.search(query)
|
|
833
|
+
.filter((e) => this._visible(e))
|
|
834
|
+
.map(copyEntry);
|
|
645
835
|
return limit === undefined ? hits : hits.slice(0, limit);
|
|
646
836
|
}
|
|
647
837
|
|
|
@@ -659,17 +849,19 @@ export class MemoryView {
|
|
|
659
849
|
*/
|
|
660
850
|
async write(entry: MemoryEntry): Promise<void> {
|
|
661
851
|
const owner = normalizeScope(this.scope);
|
|
662
|
-
const visibility = entry.visibility ??
|
|
663
|
-
if (visibility ===
|
|
852
|
+
const visibility = entry.visibility ?? 'thread';
|
|
853
|
+
if (visibility === 'thread' && !owner.threadId) {
|
|
664
854
|
throw new Error(`Cannot write thread-private entry "${entry.id}" without a thread scope`);
|
|
665
855
|
}
|
|
666
|
-
if (visibility ===
|
|
856
|
+
if (visibility === 'project' && !owner.projectId) {
|
|
667
857
|
throw new Error(`Cannot publish "${entry.id}" to a project without a project scope`);
|
|
668
858
|
}
|
|
669
859
|
const existing = this._memory.get(entry.id);
|
|
670
860
|
if (existing) {
|
|
671
861
|
if (!existing.owner) {
|
|
672
|
-
throw new Error(
|
|
862
|
+
throw new Error(
|
|
863
|
+
`Cannot overwrite unowned legacy record "${entry.id}" through a scoped view`,
|
|
864
|
+
);
|
|
673
865
|
}
|
|
674
866
|
if (!sameOwner(existing.owner, owner)) {
|
|
675
867
|
throw new Error(`Cannot overwrite "${entry.id}": it is owned by another thread or project`);
|
|
@@ -699,7 +891,7 @@ export function fileSource(id: string, path: string): ContextSource {
|
|
|
699
891
|
id,
|
|
700
892
|
async load() {
|
|
701
893
|
const file = Bun.file(path);
|
|
702
|
-
if (!(await file.exists())) return
|
|
894
|
+
if (!(await file.exists())) return '';
|
|
703
895
|
return file.text();
|
|
704
896
|
},
|
|
705
897
|
};
|