@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.
Files changed (2) hide show
  1. package/dist/index.mjs +302 -130
  2. 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 index builder.
2740
+ * In-harness memory readers.
2741
2741
  *
2742
- * The agent has an agent-level file-based memory at `<cwd>/.memory/`,
2743
- * organized by directory:
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
- * .memory/users/<id>-<name>/<topic>.md
2746
- * .memory/projects/<project_slug>/<topic>.md
2747
- * .memory/feedback/<topic>.md
2748
- * .memory/reference/<topic>.md
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
- * The path encodes type and subject (for `users/`, the subject is the
2751
- * person's stable id with a readable name suffix); each `.md` file's
2752
- * frontmatter only carries `name` and `description`.
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
- * `buildMemoryIndex` walks `.memory/` by type directory, reads only the
2755
- * frontmatter of each `.md` (open fd → read first ~4KB → close, in
2756
- * parallel), and renders a markdown index grouped by type and (where
2757
- * applicable) by subject. Files outside the four type directories are
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
- * Mtime cache keyed by cwd — within the lifetime of a sandbox the cwd
2763
- * is fixed, so this is effectively a single-entry cache. Cache invalidates
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 TYPE_DIRS = [
2779
- "users",
2780
- "projects",
2781
- "feedback",
2782
- "reference"
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
- const TYPES_WITH_SUBJECT = new Set(["users", "projects"]);
2785
- const TYPE_LABELS = {
2786
- users: "Users",
2787
- projects: "Projects",
2788
- feedback: "Feedback",
2789
- reference: "Reference"
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 the dir exists but contains nothing in the requested scope
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
- * The mtime-keyed cache stores the raw walked entries (the cost is the FS
2799
- * walk); filtering by scope is cheap and runs per call, so two turns with
2800
- * different scopes on the same cwd render correctly from one cached walk.
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, scope }) {
2803
- const memoryDirAbs = join(cwd, MEMORY_DIRNAME);
2804
- const maxMtimeMs = await maxMtimeAcrossDir(memoryDirAbs);
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 collectEntries(memoryDirAbs, cwd),
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) => scope.kind === "user" ? entry.type === "users" && entry.subject?.startsWith(scope.userId) === true : entry.type !== "users");
2818
- return visible.length === 0 ? "" : renderIndex(visible);
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 collectEntries(rootDirAbs, cwd) {
2867
- const collected = [];
2868
- await Promise.all(TYPE_DIRS.map(async (type) => {
2869
- const typeDirAbs = join(rootDirAbs, type);
2870
- if (TYPES_WITH_SUBJECT.has(type)) {
2871
- const subjectDirs = await listSubdirs(typeDirAbs);
2872
- await Promise.all(subjectDirs.map(async (subjectDirAbs) => {
2873
- const subject = basename(subjectDirAbs);
2874
- const files = await listMdFilesShallow(subjectDirAbs);
2875
- const parsed = await Promise.all(files.map(async (file) => {
2876
- const fm = await readFrontmatterOnly(file);
2877
- if (!fm?.name || !fm?.description) return null;
2878
- return {
2879
- name: fm.name,
2880
- description: fm.description,
2881
- type,
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 renderIndex(entries) {
2936
- const byType = {
2937
- users: [],
2938
- projects: [],
2939
- feedback: [],
2940
- reference: []
2941
- };
2942
- for (const e of entries) byType[e.type].push(e);
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 type of TYPE_DIRS) {
2945
- const items = byType[type];
2946
- if (items.length === 0) continue;
2947
- sections.push(`### ${TYPE_LABELS[type]}`);
2948
- if (TYPES_WITH_SUBJECT.has(type)) {
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
- * an empty `.memory/`) so the agent knows it can persist notes. `users/` is
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. The harness builds and injects an **index** of these files (paths + one-line descriptions) into your system prompt every turn; **bodies are NOT auto-loaded** — when an index entry looks relevant, use your \`read\` tool to load that specific file.
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 shown separately, scoped to whoever you're talking to.) Each file's frontmatter declares \`name\` and \`description\` (the description is what shows up in the index, so make it a one-line behavior-triggering hook). Commit and push after writing to persist it.`;
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
- return `${instructions}\n\n## Memory index\n\n${index}`;
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 index = await buildMemoryIndex({
2994
- cwd: ctx.cwd,
2995
- scope: { kind: "shared" }
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 build failed; injecting instructions only");
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
- return `${instructions}\n\n${index}`;
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
- scope: {
3088
- kind: "user",
3089
- userId: user.id
3090
- }
3262
+ userId: user.id
3091
3263
  }),
3092
3264
  user
3093
3265
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.1565",
3
+ "version": "0.1.0-beta.1569",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",