@astrosheep/pi-context 0.20.0 → 0.22.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/README.md +22 -1
- package/dist/src/budget.js +10 -8
- package/dist/src/dream/cli.js +108 -24
- package/dist/src/dream/gates.js +13 -8
- package/dist/src/dream/git.js +71 -0
- package/dist/src/dream/lock.js +78 -37
- package/dist/src/dream/runner.js +90 -21
- package/dist/src/history-tools.js +5 -5
- package/dist/src/history.js +11 -6
- package/dist/src/index.js +14 -15
- package/dist/src/notes/address.js +31 -0
- package/dist/src/{memory → notes}/frontmatter.js +7 -5
- package/dist/src/{notes.js → notes/model.js} +1 -1
- package/dist/src/{memory → notes}/paths.js +7 -3
- package/dist/src/{memory → notes}/store.js +47 -74
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +38 -29
- package/dist/src/protocol.js +9 -4
- package/dist/src/thresholds.js +33 -3
- package/dist/src/tool-output.js +4 -1
- package/dist/src/warning.js +3 -3
- package/dist/test/agent-loop.test.js +6 -4
- package/dist/test/coherence.test.js +5 -1
- package/dist/test/dream.test.js +419 -35
- package/dist/test/history.test.js +6 -1
- package/dist/test/integration.test.js +107 -47
- package/dist/test/{memory.test.js → notes.test.js} +154 -50
- package/dist/test/pagination.property.test.js +1 -1
- package/package.json +5 -5
- package/playbook.md +30 -3
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +95 -17
- package/src/dream/gates.ts +14 -7
- package/src/dream/git.ts +73 -0
- package/src/dream/lock.ts +67 -24
- package/src/dream/runner.ts +87 -20
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +7 -5
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +8 -3
- package/src/{memory → notes}/store.ts +49 -79
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +39 -29
- package/src/protocol.ts +9 -4
- package/src/thresholds.ts +38 -6
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/dist/src/dream/apply.js +0 -87
- package/dist/src/dream/manifest.js +0 -16
- package/dist/src/memory/tools.js +0 -175
- package/src/dream/apply.ts +0 -47
- package/src/dream/manifest.ts +0 -21
- package/src/memory/tools.ts +0 -175
package/src/tool-output.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export const TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
|
|
2
|
+
export const DEFAULT_READ_WINDOW_CHARS = 12000;
|
|
3
|
+
export const MAX_READ_WINDOW_CHARS = 50000;
|
|
4
|
+
export const HISTORY_PREVIEW_CHARS = 1200;
|
|
2
5
|
|
|
3
6
|
function json(value: unknown): string {
|
|
4
7
|
return JSON.stringify(value, null, 2);
|
|
@@ -102,7 +105,7 @@ export function readCharacterWindow<T>(text: string, offsetChars: number | undef
|
|
|
102
105
|
const chars = Array.from(text);
|
|
103
106
|
const requested = offsetChars ?? 0;
|
|
104
107
|
const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
|
|
105
|
-
const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ??
|
|
108
|
+
const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS));
|
|
106
109
|
const build = (content: string): CharacterWindow => {
|
|
107
110
|
const next = resolved + Array.from(content).length;
|
|
108
111
|
return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
|
package/src/warning.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { WARNING_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, WARNING_PROMPT } from "./protocol.js";
|
|
3
3
|
import { thresholdsFor, resetThresholds, type ResolvedThresholds } from "./thresholds.js";
|
|
4
4
|
import { hasWindowMessage, currentWindowId } from "./history.js";
|
|
5
|
+
import { remainingTokens } from "./budget.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The final checkpoint warning, steered to the model once per window. Like the early
|
|
@@ -30,9 +31,8 @@ export function registerWarning(pi: ExtensionAPI, isEnabled: () => boolean): voi
|
|
|
30
31
|
pi.on("context", (_event, ctx) => {
|
|
31
32
|
const windowId = currentWindowId(ctx);
|
|
32
33
|
if (!isEnabled() || firedInWindow === windowId || hasWindowMessage(ctx, WARNING_TYPE)) return undefined;
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
34
|
+
const remaining = remainingTokens(ctx);
|
|
35
|
+
if (remaining === null) return undefined;
|
|
36
36
|
const thresholds = thresholdsFor(ctx);
|
|
37
37
|
if (!warningDue(remaining, thresholds)) return undefined;
|
|
38
38
|
firedInWindow = windowId;
|
package/dist/src/dream/apply.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, existsSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { editNote, peekNote, resolveNoteScope, updateNoteMeta, writeNote } from "../memory/store.js";
|
|
4
|
-
import { physicalPath } from "../memory/paths.js";
|
|
5
|
-
function target(ctx, value, required = true) {
|
|
6
|
-
const m = /^(session|project|global):(.*)$/.exec(value);
|
|
7
|
-
if (m) {
|
|
8
|
-
const scope = m[1];
|
|
9
|
-
const path = m[2];
|
|
10
|
-
const physical = physicalPath(scope, path, ctx);
|
|
11
|
-
if (!existsSync(physical)) {
|
|
12
|
-
if (required)
|
|
13
|
-
throw new Error(`unknown path: ${value}`);
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
return { scope, path };
|
|
17
|
-
}
|
|
18
|
-
const found = resolveNoteScope(ctx, value);
|
|
19
|
-
if (!found && required)
|
|
20
|
-
throw new Error(`unknown path: ${value}`);
|
|
21
|
-
return found ? { scope: found.scope, path: value } : undefined;
|
|
22
|
-
}
|
|
23
|
-
function body(ctx, t) { return peekNote(ctx, t.scope, t.path); }
|
|
24
|
-
export function applyManifest(ctx, home, stamp, manifest) {
|
|
25
|
-
const actions = [];
|
|
26
|
-
// Resolve every referenced note and promotion destination before the first mutation.
|
|
27
|
-
for (const m of manifest.merge ?? []) {
|
|
28
|
-
target(ctx, m.into);
|
|
29
|
-
for (const p of m.from)
|
|
30
|
-
target(ctx, p);
|
|
31
|
-
}
|
|
32
|
-
for (const p of manifest.promote ?? []) {
|
|
33
|
-
const from = target(ctx, p.path);
|
|
34
|
-
if (p.to !== "global") {
|
|
35
|
-
if (!["session", "project"].includes(p.to))
|
|
36
|
-
throw new Error(`invalid promotion scope: ${p.to}`);
|
|
37
|
-
if (existsSync(physicalPath(p.to, p.path, ctx)))
|
|
38
|
-
throw new Error(`promotion collision: ${p.path}`);
|
|
39
|
-
}
|
|
40
|
-
void from;
|
|
41
|
-
}
|
|
42
|
-
for (const p of manifest.trash ?? [])
|
|
43
|
-
target(ctx, p.path);
|
|
44
|
-
for (const merge of manifest.merge ?? []) {
|
|
45
|
-
const into = target(ctx, merge.into);
|
|
46
|
-
const sources = merge.from.map((p) => target(ctx, p));
|
|
47
|
-
const base = body(ctx, into);
|
|
48
|
-
const chunks = [base.body, ...sources.map((s) => body(ctx, s).body)].filter(Boolean);
|
|
49
|
-
const dedup = [...new Set(chunks)].join("\n\n");
|
|
50
|
-
writeNote(ctx, into.path, dedup, { scope: into.scope, origin: base.meta.origin });
|
|
51
|
-
updateNoteMeta(ctx, into.path, into.scope, (meta) => { meta.recurrence_count = (meta.recurrence_count ?? 0) + sources.length; meta.recurrence_windows = [...new Set([...(meta.recurrence_windows ?? []), ...sources.map((s) => body(ctx, s).meta.source_window).filter((x) => typeof x === "string")])]; });
|
|
52
|
-
for (const source of sources)
|
|
53
|
-
updateNoteMeta(ctx, source.path, source.scope, (meta) => { meta.status = "superseded"; meta.supersedes = merge.into; });
|
|
54
|
-
actions.push(`merged ${merge.from.join(", ")} into ${merge.into}`);
|
|
55
|
-
}
|
|
56
|
-
for (const p of manifest.promote ?? []) {
|
|
57
|
-
const from = target(ctx, p.path);
|
|
58
|
-
const m = body(ctx, from);
|
|
59
|
-
const scope = p.to;
|
|
60
|
-
if (!["session", "project", "global"].includes(scope))
|
|
61
|
-
throw new Error(`invalid promotion scope: ${p.to}`);
|
|
62
|
-
if (scope === "global") {
|
|
63
|
-
actions.push(`proposal: promote ${p.path} to global (${p.reason})`);
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
const dest = physicalPath(scope, p.path, ctx);
|
|
67
|
-
if (existsSync(dest))
|
|
68
|
-
throw new Error(`promotion collision: ${p.path}`);
|
|
69
|
-
editNote(ctx, from.path, undefined, { scope });
|
|
70
|
-
actions.push(`promoted ${p.path} to ${scope}`);
|
|
71
|
-
}
|
|
72
|
-
const trashRoot = join(home, "trash", stamp);
|
|
73
|
-
mkdirSync(trashRoot, { recursive: true });
|
|
74
|
-
for (const item of manifest.trash ?? []) {
|
|
75
|
-
const t = target(ctx, item.path);
|
|
76
|
-
const source = physicalPath(t.scope, t.path, ctx);
|
|
77
|
-
const dest = join(trashRoot, t.scope, t.path.endsWith(".md") ? t.path : `${t.path}.md`);
|
|
78
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
79
|
-
renameSync(source, dest);
|
|
80
|
-
actions.push(`trashed ${item.path}: ${item.reason}`);
|
|
81
|
-
}
|
|
82
|
-
for (const p of manifest.pending ?? [])
|
|
83
|
-
actions.push(`pending ${p.path}: ${p.reason}`);
|
|
84
|
-
for (const p of manifest.skillCandidates ?? [])
|
|
85
|
-
actions.push(`skill proposal ${p.title}: ${p.rationale}`);
|
|
86
|
-
return actions;
|
|
87
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
export function parseManifest(output) {
|
|
2
|
-
const starts = [...output.matchAll(/[\{[]/g)].map((m) => m.index ?? 0).reverse();
|
|
3
|
-
for (const start of starts) {
|
|
4
|
-
try {
|
|
5
|
-
const value = JSON.parse(output.slice(start));
|
|
6
|
-
if (!value || typeof value !== "object" || typeof value.report !== "string")
|
|
7
|
-
continue;
|
|
8
|
-
for (const key of ["merge", "promote", "trash", "pending", "skillCandidates"])
|
|
9
|
-
if (value[key] !== undefined && !Array.isArray(value[key]))
|
|
10
|
-
throw new Error("invalid array");
|
|
11
|
-
return value;
|
|
12
|
-
}
|
|
13
|
-
catch { /* try an earlier JSON start */ }
|
|
14
|
-
}
|
|
15
|
-
throw new Error("dreamer did not return a valid JSON manifest");
|
|
16
|
-
}
|
package/dist/src/memory/tools.js
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { localIso } from "../notes.js";
|
|
4
|
-
import { characterWindowHeader, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
|
|
5
|
-
import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
|
|
6
|
-
import { serializeNote, stripLeadingFrontmatter } from "./frontmatter.js";
|
|
7
|
-
import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
|
|
8
|
-
const SCOPE = Type.Optional(Type.Union([Type.Literal("session"), Type.Literal("project"), Type.Literal("global")], {
|
|
9
|
-
description: "The note's reach — which root it lives under. session: only this session needs it (checkpoints, scratch state, worker rosters); dies with the session. project: tied to the current working directory — design decisions and repo facts that future sessions here still need. global: follows you everywhere — user laws, preferences, cross-project maps. On write, picks the destination root (default: session). Omit on read/list/search to cover all three; a read resolves session → project → global and returns the first existing file.",
|
|
10
|
-
}));
|
|
11
|
-
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
12
|
-
description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
|
|
13
|
-
}));
|
|
14
|
-
/** Render epoch-ms metadata as the same local ISO timestamps the frontmatter carries. */
|
|
15
|
-
function wireMeta(meta) {
|
|
16
|
-
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
17
|
-
}
|
|
18
|
-
/** Turn a typed store refusal into the pinned error arm; unknown errors stay thrown. */
|
|
19
|
-
function failure(error) {
|
|
20
|
-
if (error instanceof NoteError) {
|
|
21
|
-
const payload = { error: error.message };
|
|
22
|
-
if (error.line_numbers)
|
|
23
|
-
payload.line_numbers = error.line_numbers;
|
|
24
|
-
if (error.edit_index !== undefined)
|
|
25
|
-
payload.edit_index = error.edit_index;
|
|
26
|
-
return output(payload);
|
|
27
|
-
}
|
|
28
|
-
throw error;
|
|
29
|
-
}
|
|
30
|
-
export function registerMemoryTools(pi) {
|
|
31
|
-
pi.registerTool(defineTool({
|
|
32
|
-
name: "notes_write",
|
|
33
|
-
label: "Notes write",
|
|
34
|
-
description: "Create or replace a note as a real markdown file under the session, project, or global note root. Keep notes small and split by topic — by what the note is about, never by who said it (authorship is origin's job); a rewrite replaces the body whole while preserving created_at and every other frontmatter key. stale: true marks the note closed so it leaves the boot index but stays readable and searchable.",
|
|
35
|
-
parameters: Type.Object({ path: Type.String(), content: Type.String(), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
36
|
-
// A batch containing write or edit runs one call at a time, so note read-modify-write cannot race.
|
|
37
|
-
executionMode: "sequential",
|
|
38
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
39
|
-
const content = params.content;
|
|
40
|
-
try {
|
|
41
|
-
const { meta } = writeNote(ctx, params.path, content, { scope: (params.scope ?? "session"), origin: (params.origin ?? "self"), stale: params.stale });
|
|
42
|
-
return output({ path: params.path, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
|
|
43
|
-
}
|
|
44
|
-
catch (error) {
|
|
45
|
-
return failure(error);
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
}));
|
|
49
|
-
pi.registerTool(defineTool({
|
|
50
|
-
name: "notes_edit",
|
|
51
|
-
label: "Notes edit",
|
|
52
|
-
description: "Edit a note body by exact-text replacement; frontmatter is never editable this way. Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of scope/origin/stale. scope/origin/stale are setters: scope moves the file, refusing when the target already exists. The success return carries resolved_scope and a diff of what changed.",
|
|
53
|
-
parameters: Type.Object({ path: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
54
|
-
executionMode: "sequential",
|
|
55
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
56
|
-
try {
|
|
57
|
-
const { meta, applied, resolved_scope, diff } = editNote(ctx, params.path, params.edits, { scope: params.scope, origin: params.origin, stale: params.stale, replaceAll: params.replace_all });
|
|
58
|
-
return output({ path: params.path, applied, resolved_scope, diff, meta: wireMeta(meta) });
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
return failure(error);
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
}));
|
|
65
|
-
pi.registerTool(defineTool({
|
|
66
|
-
name: "notes_read",
|
|
67
|
-
label: "Notes read",
|
|
68
|
-
description: "Read a character window of a note file, frontmatter included: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw frontmatter + body behind a one-line [bracketed] header naming the file, the resolved offset, the delivered char range, and the resume cursor.",
|
|
69
|
-
parameters: Type.Object({ path: Type.String(), scope: SCOPE, offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })) }, { additionalProperties: false }),
|
|
70
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
71
|
-
let note;
|
|
72
|
-
try {
|
|
73
|
-
note = readNote(ctx, params.path, { scope: params.scope });
|
|
74
|
-
}
|
|
75
|
-
catch (error) {
|
|
76
|
-
return failure(error);
|
|
77
|
-
}
|
|
78
|
-
if (!note)
|
|
79
|
-
return output({ error: "note not found", path: params.path });
|
|
80
|
-
const text = serializeNote(note.meta, note.body);
|
|
81
|
-
const totalChars = Array.from(text).length;
|
|
82
|
-
// A positive offset past the end is an addressing error, not an empty page.
|
|
83
|
-
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
84
|
-
return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, path: params.path, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
85
|
-
}
|
|
86
|
-
const created_at = localIso(note.meta.created_at);
|
|
87
|
-
const updated_at = localIso(note.meta.updated_at);
|
|
88
|
-
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
89
|
-
return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
|
|
90
|
-
const { content, ...rest } = window;
|
|
91
|
-
return outputRaw(characterWindowHeader(params.path, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { path: params.path, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
|
|
92
|
-
}, (result) => withinTextBudget(result.content[0].text));
|
|
93
|
-
},
|
|
94
|
-
}));
|
|
95
|
-
pi.registerTool(defineTool({
|
|
96
|
-
name: "notes_list",
|
|
97
|
-
label: "Notes list",
|
|
98
|
-
description: "List note files as rows carrying path, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. Without scope, all three scopes are merged; a glob pattern (* within a path segment, ** across segments) filters the virtual paths.",
|
|
99
|
-
parameters: Type.Object({ scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
100
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
101
|
-
let rows;
|
|
102
|
-
try {
|
|
103
|
-
rows = listNotes(ctx, { scope: params.scope, pattern: params.pattern ?? undefined });
|
|
104
|
-
}
|
|
105
|
-
catch (error) {
|
|
106
|
-
return failure(error);
|
|
107
|
-
}
|
|
108
|
-
const files = rows.map((row) => ({
|
|
109
|
-
path: row.path,
|
|
110
|
-
scope: row.meta.scope,
|
|
111
|
-
origin: row.meta.origin,
|
|
112
|
-
status: row.meta.status,
|
|
113
|
-
stale: row.meta.stale,
|
|
114
|
-
size_bytes: row.sizeBytes,
|
|
115
|
-
created_at: localIso(row.meta.created_at),
|
|
116
|
-
updated_at: localIso(row.meta.updated_at),
|
|
117
|
-
}));
|
|
118
|
-
return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
119
|
-
if (fits(file))
|
|
120
|
-
return file;
|
|
121
|
-
const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
|
|
122
|
-
return { ...file, path, path_truncated: true };
|
|
123
|
-
}));
|
|
124
|
-
},
|
|
125
|
-
}));
|
|
126
|
-
pi.registerTool(defineTool({
|
|
127
|
-
name: "notes_search",
|
|
128
|
-
label: "Notes search",
|
|
129
|
-
description: "Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. Without scope, all three scopes are merged and every entry carries its scope. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (the body-absolute code-point offset of the earliest match).",
|
|
130
|
-
parameters: Type.Object({ query: searchQuery(), scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
131
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
132
|
-
const queries = searchQueries(params.query);
|
|
133
|
-
let rows;
|
|
134
|
-
try {
|
|
135
|
-
rows = searchNotes(ctx, queries, { scope: params.scope, pattern: params.pattern ?? undefined });
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
return failure(error);
|
|
139
|
-
}
|
|
140
|
-
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
141
|
-
const result = rows.map((row) => {
|
|
142
|
-
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, total_chars: Array.from(match.text).length, offset_chars: match.offsetChars }));
|
|
143
|
-
return { path: row.path, scope: row.scope, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at), matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
|
|
144
|
-
});
|
|
145
|
-
// Trailing matches are dropped to fit the budget, named by matches_total; a single
|
|
146
|
-
// over-budget line is delivered as a flagged prefix; only a pathological path is
|
|
147
|
-
// middle-truncated, and then only with a visible path_truncated flag.
|
|
148
|
-
const fitFile = (file, fits) => {
|
|
149
|
-
if (fits(file))
|
|
150
|
-
return file;
|
|
151
|
-
const matches = file.matches;
|
|
152
|
-
let low = 0;
|
|
153
|
-
let high = matches.length;
|
|
154
|
-
while (low < high) {
|
|
155
|
-
const mid = Math.ceil((low + high) / 2);
|
|
156
|
-
if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) }))
|
|
157
|
-
low = mid;
|
|
158
|
-
else
|
|
159
|
-
high = mid - 1;
|
|
160
|
-
}
|
|
161
|
-
if (low >= 1)
|
|
162
|
-
return { ...file, matches: matches.slice(0, low) };
|
|
163
|
-
const first = matches[0];
|
|
164
|
-
const fitted = (text) => ({ ...file, matches: [{ ...first, text, truncated: true }] });
|
|
165
|
-
const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
|
|
166
|
-
const prefix = fitted(text);
|
|
167
|
-
if (fits(prefix))
|
|
168
|
-
return prefix;
|
|
169
|
-
const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
|
|
170
|
-
return { ...prefix, path, path_truncated: true };
|
|
171
|
-
};
|
|
172
|
-
return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
|
|
173
|
-
},
|
|
174
|
-
}));
|
|
175
|
-
}
|
package/src/dream/apply.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve, relative } from "node:path";
|
|
3
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { editNote, peekNote, resolveNoteScope, updateNoteMeta, writeNote, type Scope } from "../memory/store.js";
|
|
5
|
-
import { physicalPath, scopeDir } from "../memory/paths.js";
|
|
6
|
-
import { parseNote } from "../memory/frontmatter.js";
|
|
7
|
-
import type { Manifest } from "./manifest.js";
|
|
8
|
-
|
|
9
|
-
type Target = { scope: Scope; path: string };
|
|
10
|
-
function target(ctx: ExtensionContext, value: string, required = true): Target | undefined {
|
|
11
|
-
const m = /^(session|project|global):(.*)$/.exec(value);
|
|
12
|
-
if (m) { const scope = m[1] as Scope; const path = m[2]!; const physical = physicalPath(scope, path, ctx); if (!existsSync(physical)) { if (required) throw new Error(`unknown path: ${value}`); return undefined; } return { scope, path }; }
|
|
13
|
-
const found = resolveNoteScope(ctx, value);
|
|
14
|
-
if (!found && required) throw new Error(`unknown path: ${value}`);
|
|
15
|
-
return found ? { scope: found.scope, path: value } : undefined;
|
|
16
|
-
}
|
|
17
|
-
function body(ctx: ExtensionContext, t: Target) { return peekNote(ctx, t.scope, t.path); }
|
|
18
|
-
export function applyManifest(ctx: ExtensionContext, home: string, stamp: string, manifest: Manifest): string[] {
|
|
19
|
-
const actions: string[] = [];
|
|
20
|
-
// Resolve every referenced note and promotion destination before the first mutation.
|
|
21
|
-
for (const m of manifest.merge ?? []) { target(ctx, m.into); for (const p of m.from) target(ctx, p); }
|
|
22
|
-
for (const p of manifest.promote ?? []) { const from = target(ctx, p.path)!; if (p.to !== "global") { if (!["session", "project"].includes(p.to)) throw new Error(`invalid promotion scope: ${p.to}`); if (existsSync(physicalPath(p.to as Scope, p.path, ctx))) throw new Error(`promotion collision: ${p.path}`); } void from; }
|
|
23
|
-
for (const p of manifest.trash ?? []) target(ctx, p.path);
|
|
24
|
-
for (const merge of manifest.merge ?? []) {
|
|
25
|
-
const into = target(ctx, merge.into)!;
|
|
26
|
-
const sources = merge.from.map((p) => target(ctx, p)!);
|
|
27
|
-
const base = body(ctx, into); const chunks = [base.body, ...sources.map((s) => body(ctx, s).body)].filter(Boolean);
|
|
28
|
-
const dedup = [...new Set(chunks)].join("\n\n");
|
|
29
|
-
writeNote(ctx, into.path, dedup, { scope: into.scope, origin: base.meta.origin });
|
|
30
|
-
updateNoteMeta(ctx, into.path, into.scope, (meta) => { meta.recurrence_count = (meta.recurrence_count ?? 0) as number + sources.length; meta.recurrence_windows = [...new Set([...(meta.recurrence_windows ?? []), ...sources.map((s) => body(ctx, s).meta.source_window).filter((x): x is string => typeof x === "string")])]; });
|
|
31
|
-
for (const source of sources) updateNoteMeta(ctx, source.path, source.scope, (meta) => { meta.status = "superseded"; meta.supersedes = merge.into; });
|
|
32
|
-
actions.push(`merged ${merge.from.join(", ")} into ${merge.into}`);
|
|
33
|
-
}
|
|
34
|
-
for (const p of manifest.promote ?? []) {
|
|
35
|
-
const from = target(ctx, p.path)!; const m = body(ctx, from); const scope = p.to as Scope;
|
|
36
|
-
if (!["session", "project", "global"].includes(scope)) throw new Error(`invalid promotion scope: ${p.to}`);
|
|
37
|
-
if (scope === "global") { actions.push(`proposal: promote ${p.path} to global (${p.reason})`); continue; }
|
|
38
|
-
const dest = physicalPath(scope, p.path, ctx); if (existsSync(dest)) throw new Error(`promotion collision: ${p.path}`);
|
|
39
|
-
editNote(ctx, from.path, undefined, { scope });
|
|
40
|
-
actions.push(`promoted ${p.path} to ${scope}`);
|
|
41
|
-
}
|
|
42
|
-
const trashRoot = join(home, "trash", stamp); mkdirSync(trashRoot, { recursive: true });
|
|
43
|
-
for (const item of manifest.trash ?? []) { const t = target(ctx, item.path)!; const source = physicalPath(t.scope, t.path, ctx); const dest = join(trashRoot, t.scope, t.path.endsWith(".md") ? t.path : `${t.path}.md`); mkdirSync(dirname(dest), { recursive: true }); renameSync(source, dest); actions.push(`trashed ${item.path}: ${item.reason}`); }
|
|
44
|
-
for (const p of manifest.pending ?? []) actions.push(`pending ${p.path}: ${p.reason}`);
|
|
45
|
-
for (const p of manifest.skillCandidates ?? []) actions.push(`skill proposal ${p.title}: ${p.rationale}`);
|
|
46
|
-
return actions;
|
|
47
|
-
}
|
package/src/dream/manifest.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export type Manifest = {
|
|
2
|
-
merge?: { into: string; from: string[]; summary?: string }[];
|
|
3
|
-
promote?: { path: string; to: string; reason: string }[];
|
|
4
|
-
trash?: { path: string; reason: string }[];
|
|
5
|
-
pending?: { path: string; reason: string }[];
|
|
6
|
-
skillCandidates?: { title: string; rationale: string }[];
|
|
7
|
-
report: string;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export function parseManifest(output: string): Manifest {
|
|
11
|
-
const starts = [...output.matchAll(/[\{[]/g)].map((m) => m.index ?? 0).reverse();
|
|
12
|
-
for (const start of starts) {
|
|
13
|
-
try {
|
|
14
|
-
const value = JSON.parse(output.slice(start)) as Manifest;
|
|
15
|
-
if (!value || typeof value !== "object" || typeof value.report !== "string") continue;
|
|
16
|
-
for (const key of ["merge", "promote", "trash", "pending", "skillCandidates"]) if (value[key as keyof Manifest] !== undefined && !Array.isArray(value[key as keyof Manifest])) throw new Error("invalid array");
|
|
17
|
-
return value;
|
|
18
|
-
} catch { /* try an earlier JSON start */ }
|
|
19
|
-
}
|
|
20
|
-
throw new Error("dreamer did not return a valid JSON manifest");
|
|
21
|
-
}
|
package/src/memory/tools.ts
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { localIso } from "../notes.js";
|
|
4
|
-
import { characterWindowHeader, middleTruncate, output, outputRaw, page, prefixFit, readCharacterWindow, withinTextBudget } from "../tool-output.js";
|
|
5
|
-
import { cursor, nullableString, positiveInteger, searchQueries, searchQuery } from "../tool-schema.js";
|
|
6
|
-
import { serializeNote, stripLeadingFrontmatter, type NoteMeta, type Origin } from "./frontmatter.js";
|
|
7
|
-
import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from "./store.js";
|
|
8
|
-
import type { Scope } from "./paths.js";
|
|
9
|
-
|
|
10
|
-
const SCOPE = Type.Optional(
|
|
11
|
-
Type.Union([Type.Literal("session"), Type.Literal("project"), Type.Literal("global")], {
|
|
12
|
-
description:
|
|
13
|
-
"The note's reach — which root it lives under. session: only this session needs it (checkpoints, scratch state, worker rosters); dies with the session. project: tied to the current working directory — design decisions and repo facts that future sessions here still need. global: follows you everywhere — user laws, preferences, cross-project maps. On write, picks the destination root (default: session). Omit on read/list/search to cover all three; a read resolves session → project → global and returns the first existing file.",
|
|
14
|
-
}),
|
|
15
|
-
);
|
|
16
|
-
const ORIGIN = Type.Optional(
|
|
17
|
-
Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
18
|
-
description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
|
|
19
|
-
}),
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
/** Render epoch-ms metadata as the same local ISO timestamps the frontmatter carries. */
|
|
23
|
-
function wireMeta(meta: NoteMeta): Record<string, unknown> {
|
|
24
|
-
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Turn a typed store refusal into the pinned error arm; unknown errors stay thrown. */
|
|
28
|
-
function failure(error: unknown) {
|
|
29
|
-
if (error instanceof NoteError) {
|
|
30
|
-
const payload: Record<string, unknown> = { error: error.message };
|
|
31
|
-
if (error.line_numbers) payload.line_numbers = error.line_numbers;
|
|
32
|
-
if (error.edit_index !== undefined) payload.edit_index = error.edit_index;
|
|
33
|
-
return output(payload);
|
|
34
|
-
}
|
|
35
|
-
throw error;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function registerMemoryTools(pi: ExtensionAPI) {
|
|
39
|
-
pi.registerTool(defineTool({
|
|
40
|
-
name: "notes_write",
|
|
41
|
-
label: "Notes write",
|
|
42
|
-
description: "Create or replace a note as a real markdown file under the session, project, or global note root. Keep notes small and split by topic — by what the note is about, never by who said it (authorship is origin's job); a rewrite replaces the body whole while preserving created_at and every other frontmatter key. stale: true marks the note closed so it leaves the boot index but stays readable and searchable.",
|
|
43
|
-
parameters: Type.Object({ path: Type.String(), content: Type.String(), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
44
|
-
// A batch containing write or edit runs one call at a time, so note read-modify-write cannot race.
|
|
45
|
-
executionMode: "sequential",
|
|
46
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
47
|
-
const content = params.content;
|
|
48
|
-
try {
|
|
49
|
-
const { meta } = writeNote(ctx, params.path, content, { scope: (params.scope ?? "session") as Scope, origin: (params.origin ?? "self") as Origin, stale: params.stale });
|
|
50
|
-
return output({ path: params.path, scope: meta.scope, size_bytes: Buffer.byteLength(stripLeadingFrontmatter(content), "utf8"), meta: wireMeta(meta) });
|
|
51
|
-
} catch (error) {
|
|
52
|
-
return failure(error);
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
}));
|
|
56
|
-
|
|
57
|
-
pi.registerTool(defineTool({
|
|
58
|
-
name: "notes_edit",
|
|
59
|
-
label: "Notes edit",
|
|
60
|
-
description: "Edit a note body by exact-text replacement; frontmatter is never editable this way. Each oldText must occur exactly once unless replace_all is set; a multi-match anchor fails with its match line numbers and a zero-match anchor names the failing edit index. edits may be omitted (or empty) for a metadata-only update, which requires at least one of scope/origin/stale. scope/origin/stale are setters: scope moves the file, refusing when the target already exists. The success return carries resolved_scope and a diff of what changed.",
|
|
61
|
-
parameters: Type.Object({ path: Type.String(), edits: Type.Optional(Type.Array(Type.Object({ oldText: Type.String(), newText: Type.String() }, { additionalProperties: false }))), scope: SCOPE, origin: ORIGIN, stale: Type.Optional(Type.Boolean()), replace_all: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
62
|
-
executionMode: "sequential",
|
|
63
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
64
|
-
try {
|
|
65
|
-
const { meta, applied, resolved_scope, diff } = editNote(ctx, params.path, params.edits, { scope: params.scope as Scope | undefined, origin: params.origin as Origin | undefined, stale: params.stale, replaceAll: params.replace_all });
|
|
66
|
-
return output({ path: params.path, applied, resolved_scope, diff, meta: wireMeta(meta) });
|
|
67
|
-
} catch (error) {
|
|
68
|
-
return failure(error);
|
|
69
|
-
}
|
|
70
|
-
},
|
|
71
|
-
}));
|
|
72
|
-
|
|
73
|
-
pi.registerTool(defineTool({
|
|
74
|
-
name: "notes_read",
|
|
75
|
-
label: "Notes read",
|
|
76
|
-
description: "Read a character window of a note file, frontmatter included: offset_chars is the code-point offset to start from (default 0) — a negative value counts back from the end — and limit_chars caps the window (default 12000, max 50000). Each response delivers the longest fitting prefix of that window: concatenate pages in order to reconstruct the note. The response is the raw frontmatter + body behind a one-line [bracketed] header naming the file, the resolved offset, the delivered char range, and the resume cursor.",
|
|
77
|
-
parameters: Type.Object({ path: Type.String(), scope: SCOPE, offset_chars: Type.Optional(Type.Integer({ description: "Code-point offset to start from (default 0). A negative value counts back from the end; the response echoes the resolved absolute offset. Pass the previous next_offset_chars back unchanged to continue." })), limit_chars: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000, description: "Largest requested window in code points (default 12000). A window too large for the wire budget is cut short; next_offset_chars names where the next read resumes." })) }, { additionalProperties: false }),
|
|
78
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
79
|
-
let note: ReturnType<typeof readNote>;
|
|
80
|
-
try {
|
|
81
|
-
note = readNote(ctx, params.path, { scope: params.scope as Scope | undefined });
|
|
82
|
-
} catch (error) {
|
|
83
|
-
return failure(error);
|
|
84
|
-
}
|
|
85
|
-
if (!note) return output({ error: "note not found", path: params.path });
|
|
86
|
-
const text = serializeNote(note.meta, note.body);
|
|
87
|
-
const totalChars = Array.from(text).length;
|
|
88
|
-
// A positive offset past the end is an addressing error, not an empty page.
|
|
89
|
-
if (typeof params.offset_chars === "number" && params.offset_chars > totalChars) {
|
|
90
|
-
return output({ error: `offset_chars ${params.offset_chars} is past the end: the note has ${totalChars} chars; the largest legal offset is ${totalChars} (an empty end-read)`, path: params.path, offset_chars: params.offset_chars, total_chars: totalChars });
|
|
91
|
-
}
|
|
92
|
-
const created_at = localIso(note.meta.created_at);
|
|
93
|
-
const updated_at = localIso(note.meta.updated_at);
|
|
94
|
-
const limit_chars = Math.min(params.limit_chars ?? 12000, 50000);
|
|
95
|
-
return readCharacterWindow(text, params.offset_chars, params.limit_chars, (window) => {
|
|
96
|
-
const { content, ...rest } = window;
|
|
97
|
-
return outputRaw(characterWindowHeader(params.path, window, ` · ${note.resolvedScope} · created ${created_at} · updated ${updated_at}`), content, { path: params.path, scope: note.resolvedScope, ...rest, limit_chars, created_at, updated_at });
|
|
98
|
-
}, (result) => withinTextBudget(result.content[0].text));
|
|
99
|
-
},
|
|
100
|
-
}));
|
|
101
|
-
|
|
102
|
-
pi.registerTool(defineTool({
|
|
103
|
-
name: "notes_list",
|
|
104
|
-
label: "Notes list",
|
|
105
|
-
description: "List note files as rows carrying path, scope, origin, status, stale, size_bytes, created_at, and updated_at, most recently updated first. Without scope, all three scopes are merged; a glob pattern (* within a path segment, ** across segments) filters the virtual paths.",
|
|
106
|
-
parameters: Type.Object({ scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
107
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
108
|
-
let rows: ReturnType<typeof listNotes>;
|
|
109
|
-
try {
|
|
110
|
-
rows = listNotes(ctx, { scope: params.scope as Scope | undefined, pattern: params.pattern ?? undefined });
|
|
111
|
-
} catch (error) {
|
|
112
|
-
return failure(error);
|
|
113
|
-
}
|
|
114
|
-
const files: Array<{ path: string; scope: Scope; origin: Origin; status: string; stale: boolean; size_bytes: number; created_at: string; updated_at: string; path_truncated?: boolean }> = rows.map((row) => ({
|
|
115
|
-
path: row.path,
|
|
116
|
-
scope: row.meta.scope,
|
|
117
|
-
origin: row.meta.origin,
|
|
118
|
-
status: row.meta.status,
|
|
119
|
-
stale: row.meta.stale,
|
|
120
|
-
size_bytes: row.sizeBytes,
|
|
121
|
-
created_at: localIso(row.meta.created_at),
|
|
122
|
-
updated_at: localIso(row.meta.updated_at),
|
|
123
|
-
}));
|
|
124
|
-
return output(page(files, params.cursor ?? 0, "files", params.max_results, (file, fits) => {
|
|
125
|
-
if (fits(file)) return file;
|
|
126
|
-
const path = middleTruncate(file.path, (candidate) => fits({ ...file, path: candidate, path_truncated: true }));
|
|
127
|
-
return { ...file, path, path_truncated: true };
|
|
128
|
-
}));
|
|
129
|
-
},
|
|
130
|
-
}));
|
|
131
|
-
|
|
132
|
-
pi.registerTool(defineTool({
|
|
133
|
-
name: "notes_search",
|
|
134
|
-
label: "Notes search",
|
|
135
|
-
description: "Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. Without scope, all three scopes are merged and every entry carries its scope. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (the body-absolute code-point offset of the earliest match).",
|
|
136
|
-
parameters: Type.Object({ query: searchQuery(), scope: SCOPE, pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
137
|
-
async execute(_id, params, _signal, _update, ctx) {
|
|
138
|
-
const queries = searchQueries(params.query);
|
|
139
|
-
let rows: ReturnType<typeof searchNotes>;
|
|
140
|
-
try {
|
|
141
|
-
rows = searchNotes(ctx, queries, { scope: params.scope as Scope | undefined, pattern: params.pattern ?? undefined });
|
|
142
|
-
} catch (error) {
|
|
143
|
-
return failure(error);
|
|
144
|
-
}
|
|
145
|
-
const maxPerFile = params.max_matches_per_file ?? Number.POSITIVE_INFINITY;
|
|
146
|
-
const result: Array<{ path: string; scope: Scope; created_at: string; updated_at: string; matches_total: number; matches: Array<{ line: number; text: string; truncated: boolean; total_chars: number; offset_chars: number }>; path_truncated?: boolean }> = rows.map((row) => {
|
|
147
|
-
const matches = row.matches.map((match) => ({ line: match.line, text: match.text, truncated: false, total_chars: Array.from(match.text).length, offset_chars: match.offsetChars }));
|
|
148
|
-
return { path: row.path, scope: row.scope, created_at: localIso(row.meta.created_at), updated_at: localIso(row.meta.updated_at), matches_total: matches.length, matches: matches.slice(0, maxPerFile) };
|
|
149
|
-
});
|
|
150
|
-
// Trailing matches are dropped to fit the budget, named by matches_total; a single
|
|
151
|
-
// over-budget line is delivered as a flagged prefix; only a pathological path is
|
|
152
|
-
// middle-truncated, and then only with a visible path_truncated flag.
|
|
153
|
-
const fitFile = (file: (typeof result)[number], fits: (candidate: (typeof result)[number]) => boolean) => {
|
|
154
|
-
if (fits(file)) return file;
|
|
155
|
-
const matches = file.matches;
|
|
156
|
-
let low = 0;
|
|
157
|
-
let high = matches.length;
|
|
158
|
-
while (low < high) {
|
|
159
|
-
const mid = Math.ceil((low + high) / 2);
|
|
160
|
-
if (mid >= 1 && fits({ ...file, matches: matches.slice(0, mid) })) low = mid;
|
|
161
|
-
else high = mid - 1;
|
|
162
|
-
}
|
|
163
|
-
if (low >= 1) return { ...file, matches: matches.slice(0, low) };
|
|
164
|
-
const first = matches[0]!;
|
|
165
|
-
const fitted = (text: string): (typeof result)[number] => ({ ...file, matches: [{ ...first, text, truncated: true }] });
|
|
166
|
-
const text = prefixFit(first.text, (candidate) => fits(fitted(candidate)));
|
|
167
|
-
const prefix: (typeof result)[number] = fitted(text);
|
|
168
|
-
if (fits(prefix)) return prefix;
|
|
169
|
-
const path = middleTruncate(prefix.path, (candidate) => fits({ ...prefix, path: candidate, path_truncated: true }));
|
|
170
|
-
return { ...prefix, path, path_truncated: true };
|
|
171
|
-
};
|
|
172
|
-
return output(page(result, params.cursor ?? 0, "files", params.max_files, fitFile));
|
|
173
|
-
},
|
|
174
|
-
}));
|
|
175
|
-
}
|