@inixiative/foundry-core 0.3.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/foundry-core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -65,6 +65,8 @@ export interface PromptOpts {
65
65
  urgency?: PromptUrgency;
66
66
  /** Auto-expire after this many ms. Resolves as rejected. */
67
67
  timeoutMs?: number;
68
+ /** Withdraws the prompt (the asker no longer needs an answer): it expires, resolved rejected by policy. */
69
+ signal?: AbortSignal;
68
70
  meta?: Record<string, unknown>;
69
71
  }
70
72
 
@@ -159,24 +161,21 @@ export class ActionQueue {
159
161
  // Register the waiter before notifying: a listener may resolve synchronously.
160
162
  const settled = new Promise<ActionResolution>((resolve) => {
161
163
  let timer: ReturnType<typeof setTimeout> | undefined;
164
+ const expire = (by: ActionResolution['by']) => {
165
+ if (prompt.status !== 'pending') return;
166
+ if (timer) clearTimeout(timer);
167
+ const resolution: ActionResolution = { by, action: 'rejected', timestamp: Date.now() };
168
+ prompt.status = 'expired';
169
+ prompt.resolution = resolution;
170
+ this._waiters.delete(id);
171
+ resolve(resolution);
172
+ this._settled(prompt);
173
+ };
162
174
 
163
- if (opts.timeoutMs) {
164
- timer = setTimeout(() => {
165
- if (prompt.status !== 'pending') return;
166
- const resolution: ActionResolution = {
167
- by: 'timeout',
168
- action: 'rejected',
169
- timestamp: Date.now(),
170
- };
171
- prompt.status = 'expired';
172
- prompt.resolution = resolution;
173
- this._waiters.delete(id);
174
- resolve(resolution);
175
- this._settled(prompt);
176
- }, opts.timeoutMs);
177
- }
178
-
175
+ if (opts.timeoutMs) timer = setTimeout(() => expire('timeout'), opts.timeoutMs);
179
176
  this._waiters.set(id, { resolve, timer });
177
+ if (opts.signal?.aborted) queueMicrotask(() => expire('policy'));
178
+ else opts.signal?.addEventListener('abort', () => expire('policy'), { once: true });
180
179
  });
181
180
 
182
181
  // Notify listeners (viewer badge, event stream, etc.)
@@ -58,7 +58,6 @@ export const DEFAULT_MEMORY_SELECTION: MemorySelectionPolicy = Object.freeze({
58
58
  'pin',
59
59
  'pinned',
60
60
  'convention',
61
- 'correction',
62
61
  'decision',
63
62
  'requirement',
64
63
  ]),
@@ -72,6 +71,8 @@ export const DEFAULT_MEMORY_SELECTION: MemorySelectionPolicy = Object.freeze({
72
71
  'domain_learning',
73
72
  'tool_observation',
74
73
  'guard_finding',
74
+ // Captured feedback; acting on it is an explicit resolution (docs/FEEDBACK-LOOP.md).
75
+ 'correction',
75
76
  ]),
76
77
  recentLimit: 8,
77
78
  maxEntryChars: 1200,
@@ -305,7 +306,7 @@ export function selectMemory(
305
306
  };
306
307
  const withExcerpt = (x: Excerpt) => (x.truncated ? { truncated: true, ranges: x.ranges } : {});
307
308
 
308
- // 1. Pinned: explicit instructions, conventions, corrections, decisions. Injected in full, never
309
+ // 1. Pinned: explicit instructions, conventions, decisions. Injected in full, never
309
310
  // excerpted. A record beyond the hard cap is named, not paraphrased, so the model knows a rule
310
311
  // exists that it must read before acting instead of receiving its introduction as the rule.
311
312
  const pinnedLines: string[] = [];
@@ -1,4 +1,40 @@
1
1
  export { FileMemory, fileSource, inlineSource, type MemoryEntry } from './file-memory';
2
2
  export { HttpMemory } from './http-memory';
3
- export { claudemdSource, MarkdownDocs } from './markdown-docs';
3
+ export {
4
+ claudemdSource,
5
+ formatInstructionFiles,
6
+ type InstructionFile,
7
+ type InstructionReadOptions,
8
+ instructionImports,
9
+ MarkdownDocs,
10
+ readInstructionFile,
11
+ resolveInstructionImport,
12
+ secretInstructionPath,
13
+ stripInstructionComments,
14
+ withinAny,
15
+ withinPath,
16
+ } from './markdown-docs';
17
+ export {
18
+ dedupeHierarchy,
19
+ discoverHierarchy,
20
+ expandHierarchyPath,
21
+ type HierarchyDedupeOptions,
22
+ type HierarchyDiscoverOptions,
23
+ type HierarchyDuplicate,
24
+ type HierarchyDuplicateKind,
25
+ type HierarchyFile,
26
+ type HierarchyFileSource,
27
+ type HierarchyLocation,
28
+ type HierarchyMatch,
29
+ type HierarchyNode,
30
+ type HierarchyRefusal,
31
+ type HierarchySource,
32
+ type HierarchySpan,
33
+ type HierarchyStack,
34
+ type HierarchyWalkSource,
35
+ hierarchyFingerprint,
36
+ markdownSections,
37
+ type SelectedHierarchyNode,
38
+ selectHierarchyNodes,
39
+ } from './markdown-hierarchy';
4
40
  export { type SqliteEntry, SqliteMemory } from './sqlite-memory';
@@ -1,4 +1,7 @@
1
- import { basename, extname, join, relative } from 'node:path';
1
+ import { realpathSync } from 'node:fs';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
2
5
  import type { ContextSource } from '../context-layer';
3
6
  import type { ContextRef, HydrationAdapter } from '../hydrator';
4
7
 
@@ -198,16 +201,251 @@ function extractHeadings(
198
201
  return { title, headings };
199
202
  }
200
203
 
204
+ /** One instruction file as loaded: the file itself or a file it imports with `@path`. */
205
+ export interface InstructionFile {
206
+ readonly path: string;
207
+ /** Text with block-level HTML comments removed, as Claude Code injects it. */
208
+ readonly content: string;
209
+ /** Import hops from the file the chain started at (0 for that file). */
210
+ readonly depth: number;
211
+ /** The file whose `@path` loaded this one. */
212
+ readonly importedBy?: string;
213
+ }
214
+
215
+ export interface InstructionReadOptions {
216
+ /** Home directory for `@~/` imports. Default: the process home. */
217
+ home?: string;
218
+ /** Import hops followed from the first file. Claude Code follows four. */
219
+ maxDepth?: number;
220
+ /** Real paths already loaded. Shared across a stack, it loads each file once and ends cycles. */
221
+ seen?: Set<string>;
222
+ /** Whether an import target may load; Claude Code asks before loading imports outside the project. */
223
+ allows?: (path: string) => boolean;
224
+ /** Called when a file is reached again after it already loaded (`seen`); it is not loaded twice. */
225
+ onDuplicate?: (reach: { path: string; real: string; importedBy?: string }) => void;
226
+ /** Called with every path consulted, found or not, so a caller can tell when any of them changes. */
227
+ onProbe?: (path: string) => void;
228
+ /** Instruction homes judged only below themselves by the secret check (`secretInstructionPath`). */
229
+ trusted?: readonly string[];
230
+ }
231
+
232
+ /**
233
+ * Claude Code imports a file with no extension or a text extension, and skips the rest (`.png`, an
234
+ * unknown extension, or `notes.md.` with trailing punctuation). Its own list is longer; this covers
235
+ * the text formats an instruction file plausibly imports.
236
+ */
237
+ const TEXT_EXTENSIONS = new Set(
238
+ (
239
+ 'md mdx markdown txt text rst adoc org json jsonc json5 yaml yml toml ini cfg conf xml csv tsv ' +
240
+ 'html htm css scss sass less ts tsx mts cts js jsx mjs cjs py pyi rb go rs java kt kts swift c h cc ' +
241
+ 'cpp hpp cs php sh bash zsh fish ps1 bat sql graphql gql proto prisma tf hcl lua r scala ex exs erl ' +
242
+ 'hs ml clj dart pl vue svelte astro gradle lock log diff patch'
243
+ )
244
+ .split(' ')
245
+ .map((ext) => `.${ext}`),
246
+ );
247
+
248
+ /** Directories that hold credentials and tokens (`~/.config/gh/hosts.yml`, `~/.local/share/*`, `.git/config`). */
249
+ const CREDENTIAL_DIRECTORIES = new Set([
250
+ '.ssh',
251
+ '.aws',
252
+ '.gnupg',
253
+ '.kube',
254
+ '.docker',
255
+ '.config',
256
+ '.local',
257
+ '.git',
258
+ '.azure',
259
+ '.gcloud',
260
+ '.oci',
261
+ '.password-store',
262
+ '.terraform.d',
263
+ '.cargo',
264
+ '.gem',
265
+ '.npm',
266
+ '.pki',
267
+ '.vault',
268
+ 'Keychains',
269
+ ]);
270
+
271
+ /**
272
+ * Never loaded, whatever a file asks or a symlink points at: dotfiles (`.env`, `.npmrc`), key
273
+ * material and anything under a credential directory. Claude Code would import some of these;
274
+ * Foundry refuses because a committed file must not be able to pull a secret into a shared context.
275
+ * Within a `trusted` root (an instruction home such as a CLAUDE_CONFIG_DIR under `~/.config`) only
276
+ * the part below the root is judged.
277
+ */
278
+ export function secretInstructionPath(path: string, trusted: readonly string[] = []): boolean {
279
+ const root = trusted.find((dir) => withinPath(path, dir));
280
+ const rest = root ? relative(root, path) : path;
281
+ const name = basename(path);
282
+ return (
283
+ name.startsWith('.') ||
284
+ /^id_[a-z0-9]+/i.test(name) ||
285
+ /\.(pem|key|p12|pfx|keystore|jks)$/i.test(name) ||
286
+ rest.split(sep).some((part) => CREDENTIAL_DIRECTORIES.has(part))
287
+ );
288
+ }
289
+
290
+ /** Claude Code skips an instruction file larger than this. */
291
+ export const INSTRUCTION_FILE_LIMIT = 4 * 1024 * 1024;
292
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
293
+
294
+ /** Splits markdown into prose and fenced-code segments, in order. */
295
+ export function fenceSegments(text: string): Array<{ code: boolean; text: string }> {
296
+ const segments: Array<{ code: boolean; text: string }> = [];
297
+ let fence: string | undefined;
298
+ let lines: string[] = [];
299
+ const flush = (code: boolean) => {
300
+ if (lines.length) segments.push({ code, text: lines.join('\n') });
301
+ lines = [];
302
+ };
303
+ for (const line of text.split('\n')) {
304
+ const marker = FENCE.exec(line)?.[1];
305
+ if (!fence && marker) {
306
+ flush(false);
307
+ fence = marker;
308
+ lines.push(line);
309
+ } else if (
310
+ fence &&
311
+ marker &&
312
+ marker[0] === fence[0] &&
313
+ marker.length >= fence.length &&
314
+ !line.trim().slice(marker.length).trim()
315
+ ) {
316
+ lines.push(line);
317
+ flush(true);
318
+ fence = undefined;
319
+ } else lines.push(line);
320
+ }
321
+ flush(fence !== undefined);
322
+ return segments;
323
+ }
324
+
325
+ /** Removes block-level HTML comments outside fenced code, as Claude Code does before injecting. */
326
+ export function stripInstructionComments(text: string): string {
327
+ return fenceSegments(text)
328
+ .map((segment) =>
329
+ segment.code
330
+ ? segment.text
331
+ : segment.text.replace(/^[ \t]*<!--[\s\S]*?-->[ \t]*(?:\n|$)/gm, ''),
332
+ )
333
+ .join('\n');
334
+ }
335
+
336
+ /**
337
+ * `@path` imports in document order. An import starts at a line start or after whitespace, so
338
+ * `"@x"` and `a@x` are not imports; `\ ` escapes a space; code spans and fenced blocks are skipped.
339
+ */
340
+ export function instructionImports(text: string): string[] {
341
+ const imports: string[] = [];
342
+ for (const segment of fenceSegments(text)) {
343
+ if (segment.code) continue;
344
+ // A code span never crosses a blank line; an indented block after a blank line is code.
345
+ for (const paragraph of segment.text.split(/\n[ \t]*\n/)) {
346
+ if (/^( {4}|\t)/.test(paragraph)) continue;
347
+ const prose = paragraph.replace(/(`+)[\s\S]*?[^`]\1(?!`)|(`+)\2/g, ' ');
348
+ for (const match of prose.matchAll(/(?:^|\s)@((?:\\ |[^\s])+)/g))
349
+ imports.push(match[1]!.replace(/\\ /g, ' '));
350
+ }
351
+ }
352
+ return imports;
353
+ }
354
+
355
+ /** Where an import points: `~/` is the home directory, a relative path is relative to the importing file. */
356
+ export function resolveInstructionImport(target: string, importer: string, home = homedir()) {
357
+ if (target === '~' || target.startsWith('~/')) return resolve(home, target.slice(2));
358
+ return isAbsolute(target) ? resolve(target) : resolve(dirname(importer), target);
359
+ }
360
+
361
+ /**
362
+ * Read an instruction file and what it imports, the way Claude Code loads CLAUDE.md: the file,
363
+ * then each import depth-first in document order, up to four hops. A missing, non-file, non-text,
364
+ * oversized or already-loaded target is skipped, so a cycle ends and a file shared by two importers
365
+ * loads once.
366
+ */
367
+ export async function readInstructionFile(
368
+ path: string,
369
+ options: InstructionReadOptions = {},
370
+ ): Promise<InstructionFile[]> {
371
+ const seen = options.seen ?? new Set<string>();
372
+ const maxDepth = options.maxDepth ?? 4;
373
+ const files: InstructionFile[] = [];
374
+ const visit = async (file: string, depth: number, importedBy?: string) => {
375
+ options.onProbe?.(file);
376
+ let real: string;
377
+ try {
378
+ real = await realpath(file);
379
+ const info = await stat(real);
380
+ if (!info.isFile() || info.size > INSTRUCTION_FILE_LIMIT) return;
381
+ } catch {
382
+ return;
383
+ }
384
+ // An import is judged on where it resolves and where its symlinks lead.
385
+ if (
386
+ importedBy &&
387
+ (secretInstructionPath(real, options.trusted) || (options.allows && !options.allows(real)))
388
+ )
389
+ return;
390
+ if (seen.has(real)) {
391
+ options.onDuplicate?.({ path: file, real, ...(importedBy ? { importedBy } : {}) });
392
+ return;
393
+ }
394
+ seen.add(real);
395
+ const content = stripInstructionComments(await Bun.file(real).text());
396
+ files.push({ path: file, content, depth, ...(importedBy ? { importedBy } : {}) });
397
+ if (depth >= maxDepth) return;
398
+ for (const target of instructionImports(content)) {
399
+ const next = resolveInstructionImport(target, file, options.home);
400
+ const ext = extname(next).toLowerCase();
401
+ if ((ext && !TEXT_EXTENSIONS.has(ext)) || secretInstructionPath(next, options.trusted))
402
+ continue;
403
+ if (!options.allows || options.allows(next)) await visit(next, depth + 1, file);
404
+ }
405
+ };
406
+ await visit(resolve(path), 0);
407
+ return files;
408
+ }
409
+
410
+ /** One file, then each file it imports under its own `Contents of` heading. */
411
+ export function formatInstructionFiles(files: readonly InstructionFile[]): string {
412
+ return files
413
+ .filter((file) => file.content.trim())
414
+ .map((file) =>
415
+ file.importedBy ? `Contents of ${file.path}:\n\n${file.content.trim()}` : file.content.trim(),
416
+ )
417
+ .join('\n\n');
418
+ }
419
+
420
+ /** Whether `path` is `root` or inside it. */
421
+ export function withinPath(path: string, root: string): boolean {
422
+ const rel = relative(root, path);
423
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
424
+ }
425
+
426
+ /** A boundary test for `roots`, as written and with symlinks resolved, so a real path compares too. */
427
+ export function withinAny(roots: readonly string[]): (path: string) => boolean {
428
+ const all = roots.flatMap((root) => {
429
+ try {
430
+ return [root, realpathSync(root)];
431
+ } catch {
432
+ return [root];
433
+ }
434
+ });
435
+ return (path) => all.some((root) => withinPath(path, root));
436
+ }
437
+
201
438
  /**
202
- * Convenience: create a ContextSource from a single CLAUDE.md or similar file.
439
+ * A ContextSource for one CLAUDE.md or similar file, with its `@path` imports resolved. Imports
440
+ * load from the file's own directory tree only.
203
441
  */
204
442
  export function claudemdSource(id: string, path: string): ContextSource {
205
443
  return {
206
444
  id,
207
445
  async load() {
208
- const file = Bun.file(path);
209
- if (!(await file.exists())) return '';
210
- return file.text();
446
+ return formatInstructionFiles(
447
+ await readInstructionFile(path, { allows: withinAny([dirname(resolve(path))]) }),
448
+ );
211
449
  },
212
450
  };
213
451
  }