Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
53 lines
2.8 KiB
TypeScript
53 lines
2.8 KiB
TypeScript
import type { VaultNote } from './models';
|
|
|
|
// Stay below the shared tool fitter's 8,000-character string limit.
|
|
export const MAX_NOTE_READ_CHARS = 6000;
|
|
|
|
/** Bounded reads use JavaScript (UTF-16) offsets, including for literal search. */
|
|
export function readVaultNote(note: VaultNote, params: Record<string, unknown>) {
|
|
const offset = params.offset ?? 0;
|
|
const maxChars = params.maxChars ?? MAX_NOTE_READ_CHARS;
|
|
const query = params.query;
|
|
if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset > note.content.length) {
|
|
return { ok: false, error: 'offset must be an integer within the note content.' };
|
|
}
|
|
if (typeof maxChars !== 'number' || !Number.isSafeInteger(maxChars) || maxChars < 2) {
|
|
return { ok: false, error: 'maxChars must be an integer of at least 2 (capped at 6000).' };
|
|
}
|
|
if (query !== undefined && (typeof query !== 'string' || query.length === 0 || query.length > 200)) {
|
|
return { ok: false, error: 'query must be a nonempty literal string of at most 200 characters.' };
|
|
}
|
|
if (params.expectedUpdatedAt !== undefined && params.expectedUpdatedAt !== note.updatedAt) {
|
|
return { ok: false, error: 'The note changed. Restart reading from offset 0 without expectedUpdatedAt.' };
|
|
}
|
|
const splitsPair = (position: number) => position > 0
|
|
&& /[\uD800-\uDBFF]/.test(note.content[position - 1] ?? '')
|
|
&& /[\uDC00-\uDFFF]/.test(note.content[position] ?? '');
|
|
if (splitsPair(offset)) return { ok: false, error: 'offset splits a Unicode character. Use the returned nextOffset.' };
|
|
|
|
const matchOffset = typeof query === 'string' ? note.content.indexOf(query, offset) : undefined;
|
|
let start = matchOffset === undefined ? offset : matchOffset < 0 ? note.content.length : matchOffset;
|
|
if (splitsPair(start)) start -= 1;
|
|
let end = Math.min(note.content.length, start + Math.min(maxChars, MAX_NOTE_READ_CHARS));
|
|
if (splitsPair(end)) end -= 1;
|
|
let nextOffset: number | null = end < note.content.length ? end : null;
|
|
if (matchOffset !== undefined) {
|
|
// Advance past the match start, not the excerpt, so nearby/overlapping hits remain discoverable.
|
|
let searchFrom = matchOffset + 1;
|
|
if (splitsPair(searchFrom)) searchFrom += 1;
|
|
nextOffset = matchOffset >= 0 && searchFrom < note.content.length ? searchFrom : null;
|
|
}
|
|
return {
|
|
ok: true,
|
|
// Large optional metadata must not defeat the body limit.
|
|
note: { id: note.id, title: Array.from(note.title).slice(0, 120).join(''),
|
|
content: note.content.slice(start, end), createdAt: note.createdAt, updatedAt: note.updatedAt },
|
|
offset: start,
|
|
endOffset: end,
|
|
totalChars: note.content.length,
|
|
nextOffset,
|
|
hasMore: nextOffset !== null,
|
|
...(matchOffset === undefined ? {} : { matchOffset: matchOffset < 0 ? null : matchOffset }),
|
|
};
|
|
}
|