@skydiveai/pi-extensions 0.1.0-beta.1565 → 0.1.0-beta.1569
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/dist/index.mjs +302 -130
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { DefaultExecutionEventBusManager, DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
|
|
3
3
|
import { UserBuilder, restHandler } from "@a2a-js/sdk/server/express";
|
|
4
4
|
import { buildAgentCard, chainMiddleware, composeHandlers, createAgentExecutor, createProtocolHandlers, getCurrentTraceparent, logger, mountAt, requestHeaders, requestUrl, webHandlerToMiddleware } from "@skydiveai/pi-server";
|
|
5
|
-
import { mkdir, open, readFile, readdir, stat, unlink } from "node:fs/promises";
|
|
5
|
+
import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
@@ -2737,37 +2737,35 @@ const currentTimeExtension = (pi) => {
|
|
|
2737
2737
|
//#endregion
|
|
2738
2738
|
//#region src/memory.ts
|
|
2739
2739
|
/**
|
|
2740
|
-
* In-harness memory
|
|
2740
|
+
* In-harness memory readers.
|
|
2741
2741
|
*
|
|
2742
|
-
* The agent has an agent-level file-based memory at `<cwd>/.memory/`,
|
|
2743
|
-
*
|
|
2742
|
+
* The agent has an agent-level file-based memory at `<cwd>/.memory/`, split
|
|
2743
|
+
* into two halves that are surfaced differently:
|
|
2744
2744
|
*
|
|
2745
|
-
*
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2745
|
+
* 1. Shared knowledge (projects, lessons, external systems) — indexed by a
|
|
2746
|
+
* single hand-maintained `<cwd>/.memory/MEMORY.md` that the *agent* writes
|
|
2747
|
+
* and curates, Claude-Code style: one line per fact pointing at the file
|
|
2748
|
+
* that holds it. `readMemoryIndexFile` just reads that file; the agent owns
|
|
2749
|
+
* its contents. This is the whole index for the shared half — there is no
|
|
2750
|
+
* derived walk and no per-directory `MEMORY.md`.
|
|
2749
2751
|
*
|
|
2750
|
-
*
|
|
2751
|
-
*
|
|
2752
|
-
*
|
|
2752
|
+
* 2. Per-person memory — `.memory/users/<id>-<name>/<topic>.md`. This half is
|
|
2753
|
+
* *derived*, not hand-maintained, because it has to be filtered to the one
|
|
2754
|
+
* person on the current turn (a single hand-written index couldn't be
|
|
2755
|
+
* scoped per-user without leaking one person's notes into another's
|
|
2756
|
+
* conversation). `buildMemoryIndex` walks a single user's directory, reads
|
|
2757
|
+
* only the frontmatter of each `.md` (open fd → read first ~4KB → close, in
|
|
2758
|
+
* parallel), and renders an index. Each `.md`'s frontmatter carries `name`
|
|
2759
|
+
* and `description`; bodies are never read — the agent loads a specific
|
|
2760
|
+
* memory's body on demand via the `read` tool.
|
|
2753
2761
|
*
|
|
2754
|
-
*
|
|
2755
|
-
*
|
|
2756
|
-
*
|
|
2757
|
-
*
|
|
2758
|
-
* ignored. Bodies are never read — the agent loads a specific memory's
|
|
2759
|
-
* body on demand via the `read` tool when the index entry says it's
|
|
2760
|
-
* relevant.
|
|
2762
|
+
* Mtime cache (for the derived per-user half) keyed by cwd — within the
|
|
2763
|
+
* lifetime of a sandbox the cwd is fixed, so this is effectively a single-entry
|
|
2764
|
+
* cache. It invalidates when any `.md` under `users/` is added/modified/
|
|
2765
|
+
* deleted; turns where memory didn't change reuse the cached entries.
|
|
2761
2766
|
*
|
|
2762
|
-
*
|
|
2763
|
-
*
|
|
2764
|
-
* when any `.md` in the tree is added/modified/deleted; turns where
|
|
2765
|
-
* memory didn't change reuse the cached string.
|
|
2766
|
-
*
|
|
2767
|
-
* Frontmatter is parsed as YAML (`yaml` package) and validated with a
|
|
2768
|
-
* zod schema — files that don't match the shape are dropped from the
|
|
2769
|
-
* index. The same schema can be reused at write time if we want to
|
|
2770
|
-
* validate before commit.
|
|
2767
|
+
* Frontmatter is parsed as YAML (`yaml` package) and validated with a zod
|
|
2768
|
+
* schema — files that don't match the shape are dropped from the index.
|
|
2771
2769
|
*/
|
|
2772
2770
|
const FRONTMATTER_READ_BYTES = 4096;
|
|
2773
2771
|
const FrontmatterSchema = z.object({
|
|
@@ -2775,33 +2773,118 @@ const FrontmatterSchema = z.object({
|
|
|
2775
2773
|
description: z.string().min(1)
|
|
2776
2774
|
}).passthrough();
|
|
2777
2775
|
const MEMORY_DIRNAME = ".memory";
|
|
2778
|
-
const
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2776
|
+
const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
2777
|
+
const USERS_DIRNAME = "users";
|
|
2778
|
+
/**
|
|
2779
|
+
* The shared-knowledge type dirs from the old frontmatter-indexed layout, used
|
|
2780
|
+
* only to seed a `MEMORY.md` for agents created before it existed (see
|
|
2781
|
+
* `seedMemoryIndexFile`). `users/` is deliberately excluded — per-person memory
|
|
2782
|
+
* stays derived and never lands in the shared, un-scoped `MEMORY.md`.
|
|
2783
|
+
*/
|
|
2784
|
+
const LEGACY_SHARED_TYPES = [
|
|
2785
|
+
{
|
|
2786
|
+
dir: "projects",
|
|
2787
|
+
label: "Projects"
|
|
2788
|
+
},
|
|
2789
|
+
{
|
|
2790
|
+
dir: "feedback",
|
|
2791
|
+
label: "Feedback"
|
|
2792
|
+
},
|
|
2793
|
+
{
|
|
2794
|
+
dir: "reference",
|
|
2795
|
+
label: "Reference"
|
|
2796
|
+
}
|
|
2783
2797
|
];
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2798
|
+
/**
|
|
2799
|
+
* Soft budget for an injected index block. The index is read and injected into
|
|
2800
|
+
* the system prompt on every turn, so every entry costs context for the rest of
|
|
2801
|
+
* the conversation. Past this size we nudge the agent to consolidate and prune
|
|
2802
|
+
* rather than keep appending. Not a hard cap — nothing is truncated.
|
|
2803
|
+
*/
|
|
2804
|
+
const MEMORY_INDEX_SOFT_BUDGET_CHARS = 2e4;
|
|
2805
|
+
/**
|
|
2806
|
+
* Hard cap on the injected index — double the soft budget. The soft budget only
|
|
2807
|
+
* warns; this actually bounds what we inject so a runaway index can't consume
|
|
2808
|
+
* unbounded context on every turn. Past this, the index is truncated (on a line
|
|
2809
|
+
* boundary) before injection. It's a backstop, not a normal operating point.
|
|
2810
|
+
*/
|
|
2811
|
+
const MEMORY_INDEX_HARD_BUDGET_CHARS = MEMORY_INDEX_SOFT_BUDGET_CHARS * 2;
|
|
2812
|
+
/**
|
|
2813
|
+
* Cheap size summary of a rendered index block, used to surface how much of the
|
|
2814
|
+
* every-turn context budget the index is spending so the agent keeps it lean.
|
|
2815
|
+
* Counts pointer/file lines — both the derived `` - `path` — desc`` form and
|
|
2816
|
+
* the hand-maintained `- [Title](path) — hook` form — not the group headers.
|
|
2817
|
+
*/
|
|
2818
|
+
function summarizeIndex(index) {
|
|
2819
|
+
const entryCount = index.split("\n").filter((line) => /^\s*- (?:`|\[)/.test(line)).length;
|
|
2820
|
+
const charCount = index.length;
|
|
2821
|
+
return {
|
|
2822
|
+
entryCount,
|
|
2823
|
+
charCount,
|
|
2824
|
+
overBudget: charCount > MEMORY_INDEX_SOFT_BUDGET_CHARS
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* One-line size note for an index block header, e.g. `12 entries, 3187 chars`.
|
|
2829
|
+
*/
|
|
2830
|
+
function indexSizeNote(index) {
|
|
2831
|
+
const { entryCount, charCount } = summarizeIndex(index);
|
|
2832
|
+
return `${entryCount} ${entryCount === 1 ? "entry" : "entries"}, ${charCount} chars`;
|
|
2833
|
+
}
|
|
2834
|
+
/**
|
|
2835
|
+
* An explicit warning to surface to the agent when an index has grown past its
|
|
2836
|
+
* budget, or `null` when it's within budget. Extensions render this prominently
|
|
2837
|
+
* above the index so the agent prunes before it keeps appending.
|
|
2838
|
+
*/
|
|
2839
|
+
function indexBudgetWarning(index) {
|
|
2840
|
+
const { charCount, overBudget } = summarizeIndex(index);
|
|
2841
|
+
if (!overBudget) return null;
|
|
2842
|
+
return `⚠️ This memory index is ${charCount} chars, over its ${MEMORY_INDEX_SOFT_BUDGET_CHARS}-char budget. It's costing you context on every turn — consolidate duplicate entries and delete stale ones to bring it back under budget before adding anything new.`;
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Enforce the hard cap on an index before injection. Under the cap the index is
|
|
2846
|
+
* returned unchanged; over it, the index is truncated on a line boundary and a
|
|
2847
|
+
* notice is appended naming the true size so the agent knows entries are hidden
|
|
2848
|
+
* and must be pruned. This is the actual bound on injected context — callers
|
|
2849
|
+
* still report the true size via {@link indexSizeNote} so nothing is masked.
|
|
2850
|
+
*/
|
|
2851
|
+
function enforceMemoryIndexHardBudget(index) {
|
|
2852
|
+
if (index.length <= 4e4) return index;
|
|
2853
|
+
const clipped = index.slice(0, MEMORY_INDEX_HARD_BUDGET_CHARS);
|
|
2854
|
+
const lastNewline = clipped.lastIndexOf("\n");
|
|
2855
|
+
return `${lastNewline > 0 ? clipped.slice(0, lastNewline) : clipped}\n\n⚠️ Memory index truncated at ${MEMORY_INDEX_HARD_BUDGET_CHARS} chars (it is ${index.length}). Entries past this point are NOT shown. Prune the index now — delete stale entries and consolidate duplicates.`;
|
|
2856
|
+
}
|
|
2857
|
+
/**
|
|
2858
|
+
* Read the agent's hand-maintained shared index at `.memory/MEMORY.md`.
|
|
2859
|
+
* Returns the trimmed contents, or `null` when the file is absent or empty —
|
|
2860
|
+
* the agent owns this file, so we surface exactly what it wrote.
|
|
2861
|
+
*/
|
|
2862
|
+
async function readMemoryIndexFile({ cwd }) {
|
|
2863
|
+
const path = join(cwd, MEMORY_DIRNAME, MEMORY_INDEX_FILENAME);
|
|
2864
|
+
try {
|
|
2865
|
+
const trimmed = (await readFile(path, "utf-8")).trim();
|
|
2866
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
2867
|
+
} catch {
|
|
2868
|
+
return null;
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2791
2871
|
const cache = /* @__PURE__ */ new Map();
|
|
2792
2872
|
/**
|
|
2873
|
+
* Build the derived per-person index for a single user.
|
|
2874
|
+
*
|
|
2793
2875
|
* Returns:
|
|
2794
|
-
* - `null` if `.memory/` doesn't exist
|
|
2795
|
-
* - `""` if
|
|
2876
|
+
* - `null` if `.memory/users/` doesn't exist
|
|
2877
|
+
* - `""` if it exists but this user has no memory
|
|
2796
2878
|
* - rendered markdown body (no surrounding header — caller wraps)
|
|
2797
2879
|
*
|
|
2798
|
-
*
|
|
2799
|
-
*
|
|
2800
|
-
*
|
|
2880
|
+
* Scoping by id keeps one person's memory from bleeding into another's
|
|
2881
|
+
* conversation. The mtime-keyed cache stores the raw walked entries (the cost
|
|
2882
|
+
* is the FS walk); filtering by user is cheap and runs per call, so two turns
|
|
2883
|
+
* with different users on the same cwd render correctly from one cached walk.
|
|
2801
2884
|
*/
|
|
2802
|
-
async function buildMemoryIndex({ cwd,
|
|
2803
|
-
const
|
|
2804
|
-
const maxMtimeMs = await maxMtimeAcrossDir(
|
|
2885
|
+
async function buildMemoryIndex({ cwd, userId }) {
|
|
2886
|
+
const usersDirAbs = join(cwd, MEMORY_DIRNAME, USERS_DIRNAME);
|
|
2887
|
+
const maxMtimeMs = await maxMtimeAcrossDir(usersDirAbs);
|
|
2805
2888
|
if (maxMtimeMs === null) {
|
|
2806
2889
|
cache.delete(cwd);
|
|
2807
2890
|
return null;
|
|
@@ -2809,13 +2892,13 @@ async function buildMemoryIndex({ cwd, scope }) {
|
|
|
2809
2892
|
let cached = cache.get(cwd);
|
|
2810
2893
|
if (!cached || cached.builtAtMs < maxMtimeMs) {
|
|
2811
2894
|
cached = {
|
|
2812
|
-
entries: await
|
|
2895
|
+
entries: await collectUserEntries(usersDirAbs, cwd),
|
|
2813
2896
|
builtAtMs: Date.now()
|
|
2814
2897
|
};
|
|
2815
2898
|
cache.set(cwd, cached);
|
|
2816
2899
|
}
|
|
2817
|
-
const visible = cached.entries.filter((entry) =>
|
|
2818
|
-
return visible.length === 0 ? "" :
|
|
2900
|
+
const visible = cached.entries.filter((entry) => entry.subject.startsWith(userId));
|
|
2901
|
+
return visible.length === 0 ? "" : renderUserIndex(visible);
|
|
2819
2902
|
}
|
|
2820
2903
|
async function maxMtimeAcrossDir(dir) {
|
|
2821
2904
|
let dirStat;
|
|
@@ -2863,45 +2946,22 @@ async function listSubdirs(dir) {
|
|
|
2863
2946
|
}
|
|
2864
2947
|
return entries.filter((e) => e.isDirectory()).map((e) => join(dir, e.name));
|
|
2865
2948
|
}
|
|
2866
|
-
async function
|
|
2867
|
-
const
|
|
2868
|
-
await Promise.all(
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
await
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
subject,
|
|
2883
|
-
relPath: relative(cwd, file)
|
|
2884
|
-
};
|
|
2885
|
-
}));
|
|
2886
|
-
for (const e of parsed) if (e) collected.push(e);
|
|
2887
|
-
}));
|
|
2888
|
-
} else {
|
|
2889
|
-
const files = await listMdFilesShallow(typeDirAbs);
|
|
2890
|
-
const parsed = await Promise.all(files.map(async (file) => {
|
|
2891
|
-
const fm = await readFrontmatterOnly(file);
|
|
2892
|
-
if (!fm?.name || !fm?.description) return null;
|
|
2893
|
-
return {
|
|
2894
|
-
name: fm.name,
|
|
2895
|
-
description: fm.description,
|
|
2896
|
-
type,
|
|
2897
|
-
subject: null,
|
|
2898
|
-
relPath: relative(cwd, file)
|
|
2899
|
-
};
|
|
2900
|
-
}));
|
|
2901
|
-
for (const e of parsed) if (e) collected.push(e);
|
|
2902
|
-
}
|
|
2903
|
-
}));
|
|
2904
|
-
return collected;
|
|
2949
|
+
async function collectUserEntries(usersDirAbs, cwd) {
|
|
2950
|
+
const subjectDirs = await listSubdirs(usersDirAbs);
|
|
2951
|
+
return (await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
|
|
2952
|
+
const subject = basename(subjectDirAbs);
|
|
2953
|
+
const files = await listMdFilesShallow(subjectDirAbs);
|
|
2954
|
+
return (await Promise.all(files.map(async (file) => {
|
|
2955
|
+
const fm = await readFrontmatterOnly(file);
|
|
2956
|
+
if (!fm?.name || !fm?.description) return null;
|
|
2957
|
+
return {
|
|
2958
|
+
name: fm.name,
|
|
2959
|
+
description: fm.description,
|
|
2960
|
+
subject,
|
|
2961
|
+
relPath: relative(cwd, file)
|
|
2962
|
+
};
|
|
2963
|
+
}))).filter((e) => e !== null);
|
|
2964
|
+
}))).flat();
|
|
2905
2965
|
}
|
|
2906
2966
|
async function readFrontmatterOnly(filePath) {
|
|
2907
2967
|
let fh;
|
|
@@ -2932,68 +2992,180 @@ function parseFrontmatter(text) {
|
|
|
2932
2992
|
const result = FrontmatterSchema.safeParse(parsed);
|
|
2933
2993
|
return result.success ? result.data : null;
|
|
2934
2994
|
}
|
|
2935
|
-
function
|
|
2936
|
-
const
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2995
|
+
function renderUserIndex(entries) {
|
|
2996
|
+
const bySubject = /* @__PURE__ */ new Map();
|
|
2997
|
+
for (const e of entries) {
|
|
2998
|
+
const list = bySubject.get(e.subject) ?? [];
|
|
2999
|
+
list.push(e);
|
|
3000
|
+
bySubject.set(e.subject, list);
|
|
3001
|
+
}
|
|
3002
|
+
const lines = ["### Users"];
|
|
3003
|
+
for (const subject of [...bySubject.keys()].sort()) {
|
|
3004
|
+
lines.push(`- **${subject}**`);
|
|
3005
|
+
for (const e of bySubject.get(subject) ?? []) lines.push(` - \`${e.relPath}\` — ${e.description}`);
|
|
3006
|
+
}
|
|
3007
|
+
return lines.join("\n");
|
|
3008
|
+
}
|
|
3009
|
+
/**
|
|
3010
|
+
* One-time migration for agents created before `MEMORY.md` existed. If there is
|
|
3011
|
+
* no hand-maintained `.memory/MEMORY.md` yet but the agent has shared memory
|
|
3012
|
+
* files from the old frontmatter-indexed layout (`projects/`, `feedback/`,
|
|
3013
|
+
* `reference/`), derive a `MEMORY.md` from their frontmatter and write it once.
|
|
3014
|
+
* After that the agent owns the file — this never runs again for that agent and
|
|
3015
|
+
* never clobbers an existing index.
|
|
3016
|
+
*
|
|
3017
|
+
* Returns the seeded contents (also written to disk), or `null` when nothing
|
|
3018
|
+
* was seeded (index already present, or no legacy shared files). A write
|
|
3019
|
+
* failure propagates so the caller can log it; the read path then falls back to
|
|
3020
|
+
* whatever is on disk.
|
|
3021
|
+
*/
|
|
3022
|
+
async function seedMemoryIndexFile({ cwd }) {
|
|
3023
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3024
|
+
const indexPath = join(memoryDirAbs, MEMORY_INDEX_FILENAME);
|
|
3025
|
+
if (await stat(indexPath).catch(() => null)) return null;
|
|
3026
|
+
const perType = await Promise.all(LEGACY_SHARED_TYPES.map(async ({ dir, label }) => {
|
|
3027
|
+
const typeDirAbs = join(memoryDirAbs, dir);
|
|
3028
|
+
const files = [];
|
|
3029
|
+
await walkMdFiles(typeDirAbs, files);
|
|
3030
|
+
return {
|
|
3031
|
+
label,
|
|
3032
|
+
entries: (await Promise.all(files.map(async (file) => {
|
|
3033
|
+
const fm = await readFrontmatterOnly(file);
|
|
3034
|
+
if (!fm?.name || !fm?.description) return null;
|
|
3035
|
+
return {
|
|
3036
|
+
name: fm.name,
|
|
3037
|
+
description: fm.description,
|
|
3038
|
+
relPath: relative(cwd, file)
|
|
3039
|
+
};
|
|
3040
|
+
}))).filter((e) => !!e)
|
|
3041
|
+
};
|
|
3042
|
+
}));
|
|
3043
|
+
if (perType.every((group) => group.entries.length === 0)) return null;
|
|
3044
|
+
const content = renderSeededIndex(perType);
|
|
3045
|
+
await writeFile(indexPath, `${content}\n`, "utf-8");
|
|
3046
|
+
return content;
|
|
3047
|
+
}
|
|
3048
|
+
function renderSeededIndex(groups) {
|
|
2943
3049
|
const sections = [];
|
|
2944
|
-
for (const
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
const bySubject = /* @__PURE__ */ new Map();
|
|
2950
|
-
for (const e of items) {
|
|
2951
|
-
const subject = e.subject ?? "(unknown)";
|
|
2952
|
-
const list = bySubject.get(subject) ?? [];
|
|
2953
|
-
list.push(e);
|
|
2954
|
-
bySubject.set(subject, list);
|
|
2955
|
-
}
|
|
2956
|
-
const subjects = [...bySubject.keys()].sort();
|
|
2957
|
-
for (const subject of subjects) {
|
|
2958
|
-
sections.push(`- **${subject}**`);
|
|
2959
|
-
for (const e of bySubject.get(subject) ?? []) sections.push(` - \`${e.relPath}\` — ${e.description}`);
|
|
2960
|
-
}
|
|
2961
|
-
} else for (const e of items) sections.push(`- \`${e.relPath}\` — ${e.description}`);
|
|
3050
|
+
for (const { label, entries } of groups) {
|
|
3051
|
+
if (entries.length === 0) continue;
|
|
3052
|
+
sections.push(`### ${label}`);
|
|
3053
|
+
const sorted = [...entries].sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
3054
|
+
for (const e of sorted) sections.push(`- [${e.name}](${e.relPath}) — ${e.description}`);
|
|
2962
3055
|
sections.push("");
|
|
2963
3056
|
}
|
|
2964
3057
|
return sections.join("\n").trimEnd();
|
|
2965
3058
|
}
|
|
3059
|
+
/**
|
|
3060
|
+
* The version at which the hand-maintained `MEMORY.md` layout was introduced.
|
|
3061
|
+
* Used to classify an unversioned `.memory/`: if it already has a `MEMORY.md`
|
|
3062
|
+
* it's on this layout (not the pre-MEMORY.md v1 frontmatter layout), so it
|
|
3063
|
+
* shouldn't be treated as v1 and re-seeded.
|
|
3064
|
+
*/
|
|
3065
|
+
const HAND_MAINTAINED_INDEX_VERSION = 2;
|
|
3066
|
+
const VERSION_FILENAME = ".version";
|
|
3067
|
+
const MEMORY_MIGRATIONS = [{
|
|
3068
|
+
from: 1,
|
|
3069
|
+
to: 2,
|
|
3070
|
+
apply: async ({ cwd }) => {
|
|
3071
|
+
await seedMemoryIndexFile({ cwd });
|
|
3072
|
+
}
|
|
3073
|
+
}];
|
|
3074
|
+
/**
|
|
3075
|
+
* The layout version of an agent's `.memory/`:
|
|
3076
|
+
* - `null` when there's no `.memory/` at all (a fresh agent is current by
|
|
3077
|
+
* construction; nothing to migrate).
|
|
3078
|
+
* - `1` when `.memory/` exists but carries no `.version` marker — i.e. it
|
|
3079
|
+
* predates versioning.
|
|
3080
|
+
* - otherwise the integer in `.memory/.version`.
|
|
3081
|
+
*/
|
|
3082
|
+
async function readMemoryVersion(cwd) {
|
|
3083
|
+
const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
|
|
3084
|
+
if (!(await stat(memoryDirAbs).catch(() => null))?.isDirectory()) return null;
|
|
3085
|
+
const raw = await readFile(join(memoryDirAbs, VERSION_FILENAME), "utf-8").catch(() => null);
|
|
3086
|
+
if (raw === null) return await stat(join(memoryDirAbs, MEMORY_INDEX_FILENAME)).then((s) => s.isFile()).catch(() => false) ? HAND_MAINTAINED_INDEX_VERSION : 1;
|
|
3087
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
3088
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
|
|
3089
|
+
}
|
|
3090
|
+
async function writeMemoryVersion(cwd, version) {
|
|
3091
|
+
await writeFile(join(cwd, MEMORY_DIRNAME, VERSION_FILENAME), `${version}\n`, "utf-8");
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Bring an agent's `.memory/` up to `CURRENT_MEMORY_VERSION` by applying the
|
|
3095
|
+
* ordered migrations. Runs on session start. No-op when there's no `.memory/`
|
|
3096
|
+
* yet or it's already current. Migrations must be idempotent, so a lost/unwritten
|
|
3097
|
+
* version marker (the file isn't committed by the harness) only costs a repeated
|
|
3098
|
+
* no-op, never corruption. Returns the `{ from, to }` actually applied, or
|
|
3099
|
+
* `null` when nothing ran.
|
|
3100
|
+
*/
|
|
3101
|
+
async function migrateMemory({ cwd }) {
|
|
3102
|
+
const from = await readMemoryVersion(cwd);
|
|
3103
|
+
if (from === null || from >= 2) return null;
|
|
3104
|
+
let version = from;
|
|
3105
|
+
while (version < 2) {
|
|
3106
|
+
const migration = MEMORY_MIGRATIONS.find((m) => m.from === version);
|
|
3107
|
+
if (!migration) break;
|
|
3108
|
+
await migration.apply({ cwd });
|
|
3109
|
+
version = migration.to;
|
|
3110
|
+
}
|
|
3111
|
+
await writeMemoryVersion(cwd, version);
|
|
3112
|
+
return {
|
|
3113
|
+
from,
|
|
3114
|
+
to: version
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
2966
3117
|
//#endregion
|
|
2967
3118
|
//#region src/extensions/memory.ts
|
|
2968
3119
|
const log$6 = logger.child({ module: "memory-extension" });
|
|
2969
3120
|
/**
|
|
2970
3121
|
* The standing instructions for the memory system. Always injected (even with
|
|
2971
|
-
*
|
|
3122
|
+
* no `MEMORY.md`) so the agent knows it can persist notes and how. `users/` is
|
|
2972
3123
|
* described by the platform memory extension, which is the only thing that can
|
|
2973
3124
|
* scope it to a person — here we just point at it.
|
|
2974
3125
|
*/
|
|
2975
3126
|
function memoryInstructions(cwd) {
|
|
2976
3127
|
return `## Memory across conversations
|
|
2977
3128
|
|
|
2978
|
-
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo
|
|
3129
|
+
Persistent notes across conversations live at \`${cwd}/.memory/\` — plain markdown files in your repo, one fact per file. You maintain a hand-written index of them at \`${cwd}/.memory/MEMORY.md\`, and the harness injects that index into your system prompt every turn. **Bodies are NOT auto-loaded** — when an index line looks relevant, use your \`read\` tool to load that specific file.
|
|
2979
3130
|
|
|
2980
3131
|
Memory records **what happened**: facts you learned, events, investigation findings, project and system details worth carrying forward. It is NOT where behavior goes. A standing rule about how you should act — a "from now on, always/never …", a tone or format preference, a workflow convention a user wants you to follow — belongs in \`soul.md\` (see the Persona / Standing instructions section), not here. When a note is really an instruction about your behavior, write it to \`soul.md\`; when it is a fact or a record of something that occurred, write it here.
|
|
2981
3132
|
|
|
2982
|
-
Shared knowledge is laid out as \`projects/<slug>/<topic>.md\` for project and system context, \`feedback/<topic>.md\` for concrete lessons learned from something that happened (the event and what it taught you — not a free-floating rule; the rule itself, if durable, goes in \`soul.md\`), and \`reference/<topic>.md\` for how external systems work. (Notes about a specific person live under \`users/\` and are
|
|
3133
|
+
You own \`MEMORY.md\`. When you learn something durable, write the fact to its own \`.md\` file and add a one-line pointer to \`MEMORY.md\` in the form \`- [Title](relative/path.md) — one-line hook\`, where the hook is what tells future-you when to open the file. \`MEMORY.md\` is an *index*, never a store — put the actual content in the topic file and only a pointer line in \`MEMORY.md\`; do not inline a fact's body into the index even when it seems cheaper. Start each topic file with \`name:\`/\`description:\` frontmatter (the \`description\` is the one-line hook) so the index can be re-seeded, re-derived, or linted from the files themselves. When a fact changes, edit both the file and its line; when it stops being true, delete the file and its line. Shared knowledge is laid out as \`projects/<slug>/<topic>.md\` for project and system context, \`feedback/<topic>.md\` for concrete lessons learned from something that happened (the event and what it taught you — not a free-floating rule; the rule itself, if durable, goes in \`soul.md\`), and \`reference/<topic>.md\` for how external systems work. (Notes about a specific person live under \`users/\` and are indexed for you separately, scoped to whoever you're talking to — don't put people's private notes in the shared \`MEMORY.md\`.)
|
|
3134
|
+
|
|
3135
|
+
Keep \`MEMORY.md\` lean. It's re-injected on *every* turn, so a small, high-signal index is worth far more than an exhaustive one — curate it like a tightly-edited table of contents, not a log:
|
|
3136
|
+
- Be selective. Only record something durable that will matter in a *future* conversation. Don't record what only matters right now, what you can re-derive on demand, or what's already obvious from the repo.
|
|
3137
|
+
- Consolidate before you create. Before adding a line, scan \`MEMORY.md\` for one that already covers the topic; if it exists, \`read\` that file and rewrite it with the new facts merged in rather than adding a near-duplicate. One fact per file, but don't fragment a topic across many thin files.
|
|
3138
|
+
- Prune as you go. Delete lines (and their files) that are wrong, stale, or superseded. The index header reports its size — when it's flagged over budget, consolidate and delete before adding anything new.
|
|
3139
|
+
- Write dates absolute, not relative. Resolve "last week" / "yesterday" to a concrete date when you record it (e.g. "on 7/3 Dhruv told me to …"), so the note still reads correctly in a future conversation.
|
|
3140
|
+
|
|
3141
|
+
Commit and push after editing \`.memory/\` to persist it.`;
|
|
2983
3142
|
}
|
|
2984
3143
|
function composeBlock$1({ cwd, index }) {
|
|
2985
3144
|
const instructions = memoryInstructions(cwd);
|
|
2986
3145
|
if (!index || index.length === 0) return instructions;
|
|
2987
|
-
|
|
3146
|
+
const header = `## Memory index (${indexSizeNote(index)})`;
|
|
3147
|
+
const warning = indexBudgetWarning(index);
|
|
3148
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3149
|
+
return `${instructions}\n\n${warning ? `${header}\n\n${warning}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
2988
3150
|
}
|
|
2989
3151
|
const memoryExtension = (pi) => {
|
|
2990
3152
|
let cachedBlock = null;
|
|
2991
3153
|
pi.on("session_start", async (_event, ctx) => {
|
|
2992
3154
|
try {
|
|
2993
|
-
const
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
3155
|
+
const migrated = await migrateMemory({ cwd: ctx.cwd });
|
|
3156
|
+
if (migrated !== null) log$6.info({
|
|
3157
|
+
event: "memory_migrated",
|
|
3158
|
+
from: migrated.from,
|
|
3159
|
+
to: migrated.to
|
|
3160
|
+
}, "migrated memory layout to current version");
|
|
3161
|
+
} catch (err) {
|
|
3162
|
+
log$6.warn({
|
|
3163
|
+
err,
|
|
3164
|
+
event: "memory_migration_failed"
|
|
3165
|
+
}, "memory migration failed; continuing with existing index");
|
|
3166
|
+
}
|
|
3167
|
+
try {
|
|
3168
|
+
const index = await readMemoryIndexFile({ cwd: ctx.cwd });
|
|
2997
3169
|
cachedBlock = composeBlock$1({
|
|
2998
3170
|
cwd: ctx.cwd,
|
|
2999
3171
|
index
|
|
@@ -3002,7 +3174,7 @@ const memoryExtension = (pi) => {
|
|
|
3002
3174
|
log$6.warn({
|
|
3003
3175
|
err,
|
|
3004
3176
|
event: "memory_index_failed"
|
|
3005
|
-
}, "memory index
|
|
3177
|
+
}, "memory index read failed; injecting instructions only");
|
|
3006
3178
|
cachedBlock = memoryInstructions(ctx.cwd);
|
|
3007
3179
|
}
|
|
3008
3180
|
});
|
|
@@ -3061,9 +3233,12 @@ function slugifyName(name) {
|
|
|
3061
3233
|
function composeBlock({ index, user }) {
|
|
3062
3234
|
const instructions = `## Current user memory
|
|
3063
3235
|
|
|
3064
|
-
Notes about the person on this turn — the only \`users/\` memory you can see. Store anything you learn about them under \`${`.memory/users/${user.id}-${slugifyName(user.displayName)}/`}<topic>.md\`, using exactly this directory. Other people's \`users/\` notes are never shown, so never address someone by a name you only find in memory.`;
|
|
3236
|
+
Notes about the person on this turn — the only \`users/\` memory you can see. Store anything you learn about them under \`${`.memory/users/${user.id}-${slugifyName(user.displayName)}/`}<topic>.md\`, using exactly this directory. Other people's \`users/\` notes are never shown, so never address someone by a name you only find in memory. Keep it lean and be selective — this is re-injected every turn; consolidate related facts into one file and delete what's stale rather than piling on near-duplicates.`;
|
|
3065
3237
|
if (!index || index.length === 0) return instructions;
|
|
3066
|
-
|
|
3238
|
+
const warning = indexBudgetWarning(index);
|
|
3239
|
+
const header = `Memory index (${indexSizeNote(index)}):`;
|
|
3240
|
+
const rendered = enforceMemoryIndexHardBudget(index);
|
|
3241
|
+
return `${instructions}\n\n${warning ? `${warning}\n\n${header}\n\n${rendered}` : `${header}\n\n${rendered}`}`;
|
|
3067
3242
|
}
|
|
3068
3243
|
/**
|
|
3069
3244
|
* Build the platform memory extension. `channelContext` is the per-turn ref
|
|
@@ -3084,10 +3259,7 @@ function createPlatformMemoryExtension({ channelContext }) {
|
|
|
3084
3259
|
cachedBlock = composeBlock({
|
|
3085
3260
|
index: await buildMemoryIndex({
|
|
3086
3261
|
cwd: ctx.cwd,
|
|
3087
|
-
|
|
3088
|
-
kind: "user",
|
|
3089
|
-
userId: user.id
|
|
3090
|
-
}
|
|
3262
|
+
userId: user.id
|
|
3091
3263
|
}),
|
|
3092
3264
|
user
|
|
3093
3265
|
});
|