Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ stable id may stand alone; an alias shared by multiple catalog records requires
the matching unique `project_id`. Malformed, conflicting, or differently
attributed rows are excluded rather than widened into global context.

For This project, catalog discovery reads bounded README frontmatter before
opening the selected project's body. Sibling bodies stay unread; their size
does not spend the selected-file limit or prevent a valid brief. Catalog
identity checks still include slug, project alias, and stable-ID collisions.
Frontmatter is capped at 16 KiB per record, and an opening delimiter without a
closing delimiter inside that bound fails closed. Catalog files and directories
are revalidated around the selected body read so identity changes cannot reuse
the earlier selection. Shared projection selection is unchanged.

Projection work is bounded separately from visible output. One projection may
open at most 512 source files and reserve at most 16 MiB of raw source bytes.
Ordinary context, daily, project README, and signal files are capped at 1 MiB
Expand Down
134 changes: 133 additions & 1 deletion packages/core/src/projects.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import { createHash, randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { parseDocument } from "yaml";
import {
ContainedReadError,
assertContainedDirectorySnapshotsUnchanged,
inspectContainedDirectory,
inspectContainedFile,
readContainedDirectory,
readContainedFile
readContainedFile,
sameContainedFileSnapshot
} from "./contained-read.mjs";
import { isPathWithin } from "./paths.mjs";
import { stableJson } from "./json.mjs";
Expand Down Expand Up @@ -41,6 +46,9 @@ const MAX_PROJECT_ROUTE_METADATA_BYTES = 64 * 1024;
const MAX_PROJECT_ROUTE_FRONTMATTER_BYTES = 16 * 1024;
const MAX_PROJECT_ROUTE_README_BYTES = 1024 * 1024;
const MAX_PROJECT_ROUTE_STATE_BYTES = 256 * 1024;
const MAX_PROJECT_CONTEXT_FRONTMATTER_BYTES = 16 * 1024;
const MAX_PROJECT_CONTEXT_README_BYTES = 1024 * 1024;
const MAX_PROJECT_CONTEXT_CATALOG_ENTRIES = 256;
const UNSELECTABLE_PROJECT_IDENTITY_CONTENT_ERRORS = new Set([
"DOTAIOS_EVIDENCE_FILE_TOO_LARGE",
"DOTAIOS_EVIDENCE_FRONTMATTER_INVALID",
Expand Down Expand Up @@ -510,10 +518,134 @@ async function managedRepositoryReceipt(context, projectPath) {
/** Read the portable project catalog without consulting machine-local paths. */
export async function readProjectCatalog(options = {}) {
const context = createContext(options);
if (options.projectSelector) return readScopedProjectCatalog(context, options);
const records = await readProjectRecords(context);
return records.map(toProjectCatalogRecord);
}

/** Keep sibling identities for attribution checks without reading their bodies. */
async function readScopedProjectCatalog(context, { projectSelector, budget }) {
const filesystem = context.fs;
const root = context.aiosPath;
const projectsPath = path.join(root, "projects");
const listing = await readContainedDirectory(root, projectsPath, {
filesystem,
budget,
maxEntries: MAX_PROJECT_CONTEXT_CATALOG_ENTRIES,
tooManyCode: "DOTAIOS_PROJECT_DIRECTORY_LIMIT_EXCEEDED",
readdirOptions: { withFileTypes: true },
returnSnapshot: true
});
const directories = listing ? [{ path: projectsPath, snapshot: listing.snapshot }] : [];
const observations = [];
const records = [];
for (const entry of (listing?.entries || []).sort((left, right) => left.name.localeCompare(right.name))) {
if (entry.isSymbolicLink()) throw new ContainedReadError();
if (!entry.isDirectory()) continue;
validateSlug(entry.name);
const directoryPath = path.join(projectsPath, entry.name);
const directorySnapshot = await inspectContainedDirectory(root, directoryPath, { filesystem, returnSnapshot: true });
if (!directorySnapshot) throw new ContainedReadError("DOTAIOS_CONTEXT_SOURCE_CHANGED");
directories.push({ path: directoryPath, snapshot: directorySnapshot });
const readmePath = path.join(directoryPath, "README.md");
const snapshot = await inspectContainedFile(root, readmePath, { filesystem });
observations.push({ path: readmePath, snapshot });
if (!snapshot) continue;
const bytes = await readContainedFile(root, readmePath, {
filesystem,
budget,
expectedSnapshot: snapshot,
prefixBytes: MAX_PROJECT_CONTEXT_FRONTMATTER_BYTES,
frontmatterOnly: true,
stopOnMissingFrontmatter: true
});
// A missing opening delimiter is a legacy record, not permission to read
// its body. An opening without a bounded closing delimiter is invalid:
// treating truncated metadata as empty could hide an identity collision.
const hasFrontmatter = bytes.subarray(0, 5).toString("ascii").startsWith("---\n")
|| bytes.subarray(0, 5).toString("ascii") === "---\r\n";
const frontmatter = hasFrontmatter ? bytes.toString("utf8") : "";
const match = hasFrontmatter ? FRONTMATTER_RE.exec(frontmatter) : null;
if (hasFrontmatter && (
!Buffer.from(frontmatter, "utf8").equals(bytes)
|| !match
|| (!match[0].endsWith("\n") && bytes.length !== snapshot.stats.size)
)) {
throw new ContainedReadError("DOTAIOS_PROJECT_FRONTMATTER_INVALID");
}
records.push(projectRecord(entry.name, readmePath, parseMarkdownSource(frontmatter, readmePath)));
}
await revalidateCatalog();
assertUniqueProjectIds(records);
const catalog = records.map(toProjectCatalogRecord);
const scope = resolveProjectCatalogScope(projectSelector, catalog);
const selected = records.find((record) => record.slug === scope.filter);
const selectedSnapshot = observations.find((observation) => observation.path === selected.readmePath).snapshot;
const content = await readContainedFile(root, selected.readmePath, {
filesystem,
budget,
expectedSnapshot: selectedSnapshot,
maxBytes: MAX_PROJECT_CONTEXT_README_BYTES,
tooLargeCode: "DOTAIOS_CONTEXT_SOURCE_TOO_LARGE",
encoding: "utf8"
});
await revalidateCatalog();
const selectedProject = toProjectCatalogRecord(projectRecord(
selected.slug, selected.readmePath, parseMarkdownSource(content, selected.readmePath)
));
return catalog.map((project) => project.slug === selected.slug ? selectedProject : project);

async function revalidateCatalog() {
for (const observation of observations) {
const current = await inspectContainedFile(root, observation.path, { filesystem });
if (observation.snapshot === null && current === null) continue;
if (!sameContainedFileSnapshot(observation.snapshot, current)) {
throw new ContainedReadError("DOTAIOS_CONTEXT_SOURCE_CHANGED");
}
}
await assertContainedDirectorySnapshotsUnchanged(root, directories, { filesystem });
}
}

export function resolveProjectCatalogScope(reference, projects) {
if (!reference) return null;
const matches = projects.filter((project) =>
project.id === reference || project.slug === reference || project.project === reference);
if (matches.length > 1) {
const error = new TypeError(`Project reference "${reference}" is ambiguous. Use its stable id.`);
error.code = "DOTAIOS_AMBIGUOUS_PROJECT";
throw error;
}
if (matches.length === 0) {
const error = new TypeError("Project selector is unknown.");
error.code = "DOTAIOS_PROJECT_SELECTOR_UNKNOWN";
throw error;
}
const selected = matches[0];
const filter = selected.slug;
const aliases = projectAliases(selected);
const uniqueAliases = new Set(
[...aliases].filter((alias) => projects.filter((project) => projectAliases(project).has(alias)).length === 1)
);
const selectedId = typeof selected?.id === "string" && selected.id.length > 0 ? selected.id : null;
const id = selectedId && projects.filter((project) => project.id === selectedId).length === 1
? selectedId
: null;
return {
aliases,
filter,
id,
uniqueAliases,
};
}

function projectAliases(project) {
return new Set(
[project?.slug, project?.project, project?.id]
.filter((value) => typeof value === "string" && value.length > 0)
);
}

/**
* Read the minimal bounded registration projection used by project-native
* routing. This is the sole owner of portable metadata and machine-local
Expand Down
46 changes: 4 additions & 42 deletions packages/core/src/working-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
sameContainedFileSnapshot
} from "./contained-read.mjs";
import { resolveMemoryPolicy } from "./memory-policy.mjs";
import { readProjectCatalog } from "./projects.mjs";
import { readProjectCatalog, resolveProjectCatalogScope } from "./projects.mjs";
import { readSection, readSubsection } from "./sections.mjs";

export const DEFAULT_VISIBLE_CHARACTER_BUDGET = 6000;
Expand Down Expand Up @@ -156,20 +156,21 @@ export async function selectWorkingContext(aiosPath, options = {}, dependencies
maxBytes: MAX_TIMELINE_SOURCE_BYTES,
sourcePath: "memory/events.jsonl",
}),
readProjectCatalog({ aiosPath, fs: projectFilesystem }),
readProjectCatalog({ aiosPath, fs: projectFilesystem, projectSelector: requestedProject, budget: readBudget }),
]);
const authorityAfter = await inspectContainedFile(aiosPath, authorityPath, { filesystem });
if (!sameContainedFileSnapshot(authorityBefore, authorityAfter)) {
throw new ContainedReadError("DOTAIOS_CONTEXT_SOURCE_CHANGED");
}
} catch (cause) {
if (["DOTAIOS_AMBIGUOUS_PROJECT", "DOTAIOS_PROJECT_SELECTOR_UNKNOWN"].includes(cause?.code)) throw cause;
const error = new Error("DotAIOS could not read working context safely.", { cause });
error.code = "DOTAIOS_WORKING_CONTEXT_READ_FAILED";
throw error;
}
const [identity, priorities, workNote, decisionsLog, todayNote, yesterdayNote, sessionEntries, signalEntries, eventEntries, projects] = sources;

const projectScope = resolveProjectScope(requestedProject, projects);
const projectScope = resolveProjectCatalogScope(requestedProject, projects);
const projectFilter = projectScope?.filter || null;
const sessions = stableSessionOrder(sessionEntries)
.filter((session) => matchesProject(session, projectScope, memoryPolicy.mode))
Expand Down Expand Up @@ -607,45 +608,6 @@ function timelineKey(entry) {
return [entry.type, entry.source, entry.project || "", entry.project_id || "", timelineSummary(entry)].join("\n");
}

function resolveProjectScope(reference, projects) {
if (!reference) return null;
const matches = projects.filter((project) =>
project.id === reference || project.slug === reference || project.project === reference);
if (matches.length > 1) {
const error = new TypeError(`Project reference "${reference}" is ambiguous. Use its stable id.`);
error.code = "DOTAIOS_AMBIGUOUS_PROJECT";
throw error;
}
if (matches.length === 0) {
const error = new TypeError("Project selector is unknown.");
error.code = "DOTAIOS_PROJECT_SELECTOR_UNKNOWN";
throw error;
}
const selected = matches[0];
const filter = selected.slug;
const aliases = projectAliases(selected);
const uniqueAliases = new Set(
[...aliases].filter((alias) => projects.filter((project) => projectAliases(project).has(alias)).length === 1)
);
const selectedId = typeof selected?.id === "string" && selected.id.length > 0 ? selected.id : null;
const id = selectedId && projects.filter((project) => project.id === selectedId).length === 1
? selectedId
: null;
return {
aliases,
filter,
id,
uniqueAliases,
};
}

function projectAliases(project) {
return new Set(
[project?.slug, project?.project, project?.id]
.filter((value) => typeof value === "string" && value.length > 0)
);
}

function isOperationalDate(date, today, yesterday) {
return date === today || date === yesterday;
}
Expand Down
35 changes: 35 additions & 0 deletions tests/cli/brief.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import assert from "node:assert/strict";

import { buildDailyBrief } from "../../packages/cli/src/commands/brief.mjs";
import { isoDate } from "../../packages/core/src/memory.mjs";
import { buildWorkingContext } from "../../packages/core/src/working-context.mjs";

const repoRoot = path.resolve(new URL("../..", import.meta.url).pathname);
const cli = path.join(repoRoot, "packages", "cli", "src", "index.mjs");
Expand Down Expand Up @@ -41,6 +42,40 @@ function yesterday() {
return isoDate(date);
}

test("project compact CLI, hook and MCP match core context despite an oversized sibling body", async (t) => {
const { aiosPath, tempRoot } = setupAios();
t.after(() => fs.rmSync(tempRoot, { recursive: true, force: true }));
for (const slug of ["selected", "sibling"]) {
const directory = path.join(aiosPath, "projects", slug);
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "README.md"),
`---\nid: ${slug}-id\nproject: ${slug}\nstatus: active\n---\n# ${slug}\n\n${slug === "selected" ? "SELECTED_CONTEXT_BODY" : "PRIVATE_SIBLING_BODY\n" + "x".repeat(1024 * 1024)}\n`);
}
const before = snapshotTree(aiosPath);
const args = ["brief", "--compact", "--memory", "project", "--project", "selected-id", "--path", aiosPath];
const plain = run(args).stdout.trimEnd();
const hook = JSON.parse(run([...args, "--json"]).stdout);
const mcp = spawnSync(process.execPath, [path.join(repoRoot, "packages/mcp/src/server.mjs"), "--path", aiosPath], {
cwd: repoRoot,
encoding: "utf8",
input: `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: {
name: "read_working_context", arguments: { memory: "project", project: "selected-id" }
} })}\n`
});
assert.equal(mcp.status, 0, mcp.stderr);
const response = JSON.parse(mcp.stdout.trim());
assert.equal(response.error, undefined);
const mcpContext = JSON.parse(response.result.content[0].text);
const { rendered } = await buildWorkingContext(aiosPath, { memory: "project", project: "selected-id" });

assert.match(rendered, /SELECTED_CONTEXT_BODY/);
assert.doesNotMatch(rendered, /PRIVATE_SIBLING_BODY/);
assert.equal(plain, rendered);
assert.equal(hook.hookSpecificOutput.additionalContext, rendered);
assert.equal(mcpContext.markdown, rendered);
assert.deepEqual(snapshotTree(aiosPath), before);
});

test("compact brief never injects owned skill bodies", () => {
const { aiosPath } = setupAios();
const canary = "FULL_SKILL_BODY_MUST_STAY_LAZY_94A7";
Expand Down
Loading
Loading