src / notes / noteStore.ts
src / notes / noteStore.ts
import { AgenticError } from "../core/errors";
import type { InternalStorage } from "../core/internalStorage";
export interface NoteSummary {
name: string;
path: string;
bytes: number;
updatedAt: string;
}
export interface NoteContent {
name: string;
path: string;
content: string;
bytes: number;
}
export interface MemoryContent {
path: string;
content: string;
exists: boolean;
bytes: number;
}
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** Windows reserved device names cannot be used as file stems there, with or without an extension. */
const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
/** One trailing `.md` is stripped from a requested name so `todo.md` and `todo` are the same note. */
const MD_SUFFIX = /\.md$/i;
export const MAX_NOTE_BYTES = 200_000;
export const MAX_MEMORY_BYTES = 64_000;
const MEMORY_NAME = "MEMORY";
/** Serializes writes per (workspace, file) across every NoteStore instance in this process. */
const noteLocks = new Map<string, Promise<void>>();
/** True for a normalized stem that `validateNoteName` would return unchanged. */
function isNoteName(stem: string): boolean {
return (
NAME.test(stem) &&
!stem.includes("..") &&
!RESERVED_NAME.test(stem) &&
!MD_SUFFIX.test(stem)
);
}
export function validateNoteName(name: string): string {
const trimmed = (name ?? "").trim().replace(MD_SUFFIX, "");
if (!NAME.test(trimmed) || trimmed.includes("..")) {
throw new AgenticError(
"INVALID_INPUT",
"Note names use letters, digits, '.', '_' or '-', start with a letter or digit, and are at most 64 characters.",
);
}
if (RESERVED_NAME.test(trimmed)) {
throw new AgenticError(
"INVALID_INPUT",
`Note name '${trimmed}' is a reserved device name on Windows; choose another name.`,
);
}
if (MD_SUFFIX.test(trimmed)) {
throw new AgenticError(
"INVALID_INPUT",
"Note names must not end in '.md' (the extension is added automatically).",
);
}
return trimmed;
}
function assertSize(content: string, max: number, what: string): void {
if (Buffer.byteLength(content, "utf8") > max) {
throw new AgenticError("INVALID_INPUT", `${what} is limited to ${max.toLocaleString()} bytes.`);
}
}
function withTrailingNewline(content: string): string {
return content.endsWith("\n") ? content : `${content}\n`;
}
/** Existing text, a blank separator line, then the appended text; always newline-terminated. */
function appended(existing: string, content: string): string {
return existing
? `${withTrailingNewline(existing)}\n${withTrailingNewline(content)}`
: withTrailingNewline(content);
}
/**
* Scratchpad notes under `.agentic/notes/<name>.md` plus the persistent
* workspace memory file `.agentic/MEMORY.md`. Both are plugin-owned state and
* go through InternalStorage, so they are never reachable by workspace edits.
*/
export class NoteStore {
private readonly notesRelative: string;
private readonly memoryRelative: string;
public constructor(private readonly storage: InternalStorage) {
this.notesRelative = storage.relative("notes");
this.memoryRelative = storage.relative("MEMORY.md");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.notesRelative);
}
public memoryPath(): string {
return this.memoryRelative;
}
public notePath(name: string): string {
return `${this.notesRelative}/${validateNoteName(name)}.md`;
}
public async list(): Promise<NoteSummary[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.notesRelative, { withFileTypes: true });
const notes: NoteSummary[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const name = entry.name.slice(0, -3);
if (!isNoteName(name)) continue;
const path = `${this.notesRelative}/${entry.name}`;
const info = await this.storage.lstat(path);
notes.push({ name, path, bytes: info.size, updatedAt: info.mtime.toISOString() });
}
return notes.sort((a, b) => a.name.localeCompare(b.name));
}
public async read(name: string): Promise<NoteContent> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
if (!(await this.storage.exists(path))) {
throw new AgenticError("NOT_FOUND", `Note not found: ${validName}`);
}
const content = await this.storage.readText(path);
return { name: validName, path, content, bytes: Buffer.byteLength(content, "utf8") };
}
public async write(name: string, content: string): Promise<NoteSummary> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
const text = withTrailingNewline(content);
assertSize(text, MAX_NOTE_BYTES, "A note");
return await this.locked(path, async () => {
await this.storage.writeText(path, text);
return await this.summary(validName, path);
});
}
/** Creates the note when missing; otherwise separates the new text from the old with a blank line. */
public async append(name: string, content: string): Promise<NoteSummary> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
return await this.locked(path, async () => {
const existing = (await this.storage.exists(path)) ? await this.storage.readText(path) : "";
const text = appended(existing, content);
assertSize(text, MAX_NOTE_BYTES, "A note");
await this.storage.writeText(path, text);
return await this.summary(validName, path);
});
}
public async remove(name: string): Promise<void> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
await this.locked(path, async () => {
if (!(await this.storage.exists(path))) {
throw new AgenticError("NOT_FOUND", `Note not found: ${validName}`);
}
await this.storage.remove(path);
});
}
public async readMemory(): Promise<MemoryContent> {
if (!(await this.storage.exists(this.memoryRelative))) {
return { path: this.memoryRelative, content: "", exists: false, bytes: 0 };
}
const content = await this.storage.readText(this.memoryRelative);
return { path: this.memoryRelative, content, exists: true, bytes: Buffer.byteLength(content, "utf8") };
}
public async writeMemory(content: string): Promise<NoteSummary> {
const text = withTrailingNewline(content);
assertSize(text, MAX_MEMORY_BYTES, "MEMORY.md");
return await this.locked(this.memoryRelative, async () => {
await this.storage.writeText(this.memoryRelative, text);
return await this.summary(MEMORY_NAME, this.memoryRelative);
});
}
public async appendMemory(content: string): Promise<NoteSummary> {
return await this.locked(this.memoryRelative, async () => {
const existing = (await this.readMemory()).content;
const text = appended(existing, content);
assertSize(text, MAX_MEMORY_BYTES, "MEMORY.md");
await this.storage.writeText(this.memoryRelative, text);
return await this.summary(MEMORY_NAME, this.memoryRelative);
});
}
private async summary(name: string, path: string): Promise<NoteSummary> {
const info = await this.storage.lstat(path);
return { name, path, bytes: info.size, updatedAt: info.mtime.toISOString() };
}
private async locked<T>(path: string, action: () => Promise<T>): Promise<T> {
const key = `${this.storage.boundary.realRoot}\0${path}`;
const previous = noteLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
noteLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (noteLocks.get(key) === queued) noteLocks.delete(key);
}
}
}
import { AgenticError } from "../core/errors";
import type { InternalStorage } from "../core/internalStorage";
export interface NoteSummary {
name: string;
path: string;
bytes: number;
updatedAt: string;
}
export interface NoteContent {
name: string;
path: string;
content: string;
bytes: number;
}
export interface MemoryContent {
path: string;
content: string;
exists: boolean;
bytes: number;
}
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
/** Windows reserved device names cannot be used as file stems there, with or without an extension. */
const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
/** One trailing `.md` is stripped from a requested name so `todo.md` and `todo` are the same note. */
const MD_SUFFIX = /\.md$/i;
export const MAX_NOTE_BYTES = 200_000;
export const MAX_MEMORY_BYTES = 64_000;
const MEMORY_NAME = "MEMORY";
/** Serializes writes per (workspace, file) across every NoteStore instance in this process. */
const noteLocks = new Map<string, Promise<void>>();
/** True for a normalized stem that `validateNoteName` would return unchanged. */
function isNoteName(stem: string): boolean {
return (
NAME.test(stem) &&
!stem.includes("..") &&
!RESERVED_NAME.test(stem) &&
!MD_SUFFIX.test(stem)
);
}
export function validateNoteName(name: string): string {
const trimmed = (name ?? "").trim().replace(MD_SUFFIX, "");
if (!NAME.test(trimmed) || trimmed.includes("..")) {
throw new AgenticError(
"INVALID_INPUT",
"Note names use letters, digits, '.', '_' or '-', start with a letter or digit, and are at most 64 characters.",
);
}
if (RESERVED_NAME.test(trimmed)) {
throw new AgenticError(
"INVALID_INPUT",
`Note name '${trimmed}' is a reserved device name on Windows; choose another name.`,
);
}
if (MD_SUFFIX.test(trimmed)) {
throw new AgenticError(
"INVALID_INPUT",
"Note names must not end in '.md' (the extension is added automatically).",
);
}
return trimmed;
}
function assertSize(content: string, max: number, what: string): void {
if (Buffer.byteLength(content, "utf8") > max) {
throw new AgenticError("INVALID_INPUT", `${what} is limited to ${max.toLocaleString()} bytes.`);
}
}
function withTrailingNewline(content: string): string {
return content.endsWith("\n") ? content : `${content}\n`;
}
/** Existing text, a blank separator line, then the appended text; always newline-terminated. */
function appended(existing: string, content: string): string {
return existing
? `${withTrailingNewline(existing)}\n${withTrailingNewline(content)}`
: withTrailingNewline(content);
}
/**
* Scratchpad notes under `.agentic/notes/<name>.md` plus the persistent
* workspace memory file `.agentic/MEMORY.md`. Both are plugin-owned state and
* go through InternalStorage, so they are never reachable by workspace edits.
*/
export class NoteStore {
private readonly notesRelative: string;
private readonly memoryRelative: string;
public constructor(private readonly storage: InternalStorage) {
this.notesRelative = storage.relative("notes");
this.memoryRelative = storage.relative("MEMORY.md");
}
public async initialize(): Promise<void> {
await this.storage.ensureDirectory(this.notesRelative);
}
public memoryPath(): string {
return this.memoryRelative;
}
public notePath(name: string): string {
return `${this.notesRelative}/${validateNoteName(name)}.md`;
}
public async list(): Promise<NoteSummary[]> {
await this.initialize();
const entries = await this.storage.readDirectory(this.notesRelative, { withFileTypes: true });
const notes: NoteSummary[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const name = entry.name.slice(0, -3);
if (!isNoteName(name)) continue;
const path = `${this.notesRelative}/${entry.name}`;
const info = await this.storage.lstat(path);
notes.push({ name, path, bytes: info.size, updatedAt: info.mtime.toISOString() });
}
return notes.sort((a, b) => a.name.localeCompare(b.name));
}
public async read(name: string): Promise<NoteContent> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
if (!(await this.storage.exists(path))) {
throw new AgenticError("NOT_FOUND", `Note not found: ${validName}`);
}
const content = await this.storage.readText(path);
return { name: validName, path, content, bytes: Buffer.byteLength(content, "utf8") };
}
public async write(name: string, content: string): Promise<NoteSummary> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
const text = withTrailingNewline(content);
assertSize(text, MAX_NOTE_BYTES, "A note");
return await this.locked(path, async () => {
await this.storage.writeText(path, text);
return await this.summary(validName, path);
});
}
/** Creates the note when missing; otherwise separates the new text from the old with a blank line. */
public async append(name: string, content: string): Promise<NoteSummary> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
return await this.locked(path, async () => {
const existing = (await this.storage.exists(path)) ? await this.storage.readText(path) : "";
const text = appended(existing, content);
assertSize(text, MAX_NOTE_BYTES, "A note");
await this.storage.writeText(path, text);
return await this.summary(validName, path);
});
}
public async remove(name: string): Promise<void> {
const validName = validateNoteName(name);
const path = this.notePath(validName);
await this.locked(path, async () => {
if (!(await this.storage.exists(path))) {
throw new AgenticError("NOT_FOUND", `Note not found: ${validName}`);
}
await this.storage.remove(path);
});
}
public async readMemory(): Promise<MemoryContent> {
if (!(await this.storage.exists(this.memoryRelative))) {
return { path: this.memoryRelative, content: "", exists: false, bytes: 0 };
}
const content = await this.storage.readText(this.memoryRelative);
return { path: this.memoryRelative, content, exists: true, bytes: Buffer.byteLength(content, "utf8") };
}
public async writeMemory(content: string): Promise<NoteSummary> {
const text = withTrailingNewline(content);
assertSize(text, MAX_MEMORY_BYTES, "MEMORY.md");
return await this.locked(this.memoryRelative, async () => {
await this.storage.writeText(this.memoryRelative, text);
return await this.summary(MEMORY_NAME, this.memoryRelative);
});
}
public async appendMemory(content: string): Promise<NoteSummary> {
return await this.locked(this.memoryRelative, async () => {
const existing = (await this.readMemory()).content;
const text = appended(existing, content);
assertSize(text, MAX_MEMORY_BYTES, "MEMORY.md");
await this.storage.writeText(this.memoryRelative, text);
return await this.summary(MEMORY_NAME, this.memoryRelative);
});
}
private async summary(name: string, path: string): Promise<NoteSummary> {
const info = await this.storage.lstat(path);
return { name, path, bytes: info.size, updatedAt: info.mtime.toISOString() };
}
private async locked<T>(path: string, action: () => Promise<T>): Promise<T> {
const key = `${this.storage.boundary.realRoot}\0${path}`;
const previous = noteLocks.get(key) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
noteLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (noteLocks.get(key) === queued) noteLocks.delete(key);
}
}
}