@oh-my-pi/pi-coding-agent 14.6.3 → 14.6.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.6.4] - 2026-05-03
6
+ ### Added
7
+
8
+ - Added `hindsight.mentalModelsEnabled`, `hindsight.mentalModelAutoSeed`, `hindsight.mentalModelRefreshIntervalMs`, and `hindsight.mentalModelMaxRenderChars` settings to control curated Hindsight mental-model activation, seeding, refresh cadence, and prompt render budget
9
+ - Added `<mental_models>` injection to developer instructions, loading bank-level curated summaries as stable background context
10
+ - Added built-in `/memory mm` commands (`list`, `show`, `refresh`, `history`, `seed`, `reload`, `delete`) to inspect and manage mental models on the active bank
11
+ - Added scope-aware mental-model seeding for `global`, `per-project`, and `per-project-tagged` banks, including built-in seed models like user preferences, project conventions, and project decisions
12
+ - Added warning output when hashline block replacements auto-absorbed duplicate boundary lines
13
+
14
+ ### Changed
15
+
16
+ - Changed `/memory clear` and `/memory enqueue` to apply only to the current agent session’s Hindsight cache instead of all live Hindsight sessions
17
+ - Changed the prompt assembly order so `<mental_models>` blocks are appended before `<memories>` recall blocks in developer instructions
18
+
19
+ ### Fixed
20
+
21
+ - Fixed Hindsight memory prompt injection and recall/retain tool execution to resolve against the active session state, preventing context from an unrelated session from being used
22
+ - Fixed subagent `/task` sessions to persist memories into the parent agent’s Hindsight bank by explicit parent state wiring
23
+ - Fixed per-session memory retention behavior when switching or resuming sessions by rekeying Hindsight state and resetting conversation-tracking counters so first-turn recall and nth-turn retain cadence no longer leak across conversations
24
+ - Fixed the first-turn startup race so `<mental_models>` appears in the opening system prompt when mental-model loading is enabled
25
+ - Fixed retention hygiene by stripping `<mental_models>` blocks from retained content to prevent curated summaries from feeding back into future memory writes
26
+ - Fixed `<mental_models>` rendering to honor the configured character budget and truncate with an explicit truncation marker when the snapshot exceeds limits
27
+ - Fixed hashline replacements so duplicated payload boundary lines adjacent to a replaced block are absorbed into the replacement range instead of being duplicated
28
+
5
29
  ## [14.6.3] - 2026-05-03
6
30
 
7
31
  ### Breaking Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.6.3",
4
+ "version": "14.6.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.6.3",
50
- "@oh-my-pi/pi-agent-core": "14.6.3",
51
- "@oh-my-pi/pi-ai": "14.6.3",
52
- "@oh-my-pi/pi-natives": "14.6.3",
53
- "@oh-my-pi/pi-tui": "14.6.3",
54
- "@oh-my-pi/pi-utils": "14.6.3",
49
+ "@oh-my-pi/omp-stats": "14.6.5",
50
+ "@oh-my-pi/pi-agent-core": "14.6.5",
51
+ "@oh-my-pi/pi-ai": "14.6.5",
52
+ "@oh-my-pi/pi-natives": "14.6.5",
53
+ "@oh-my-pi/pi-tui": "14.6.5",
54
+ "@oh-my-pi/pi-utils": "14.6.5",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -1275,6 +1275,31 @@ export const SETTINGS_SCHEMA = {
1275
1275
 
1276
1276
  "hindsight.debug": { type: "boolean", default: false },
1277
1277
 
1278
+ "hindsight.mentalModelsEnabled": {
1279
+ type: "boolean",
1280
+ default: true,
1281
+ ui: {
1282
+ tab: "memory",
1283
+ label: "Hindsight Mental Models",
1284
+ description:
1285
+ "Read curated reflect summaries (mental models) into developer instructions at boot. Loads existing models on the bank — does not write. Pair with hindsight.mentalModelAutoSeed to also auto-create the built-in seed set.",
1286
+ condition: "hindsightActive",
1287
+ },
1288
+ },
1289
+ "hindsight.mentalModelAutoSeed": {
1290
+ type: "boolean",
1291
+ default: true,
1292
+ ui: {
1293
+ tab: "memory",
1294
+ label: "Hindsight Mental Model Auto-Seed",
1295
+ description:
1296
+ "At session start, create any built-in mental models (project-conventions, project-decisions, user-preferences) that do not yet exist on the bank.",
1297
+ condition: "hindsightActive",
1298
+ },
1299
+ },
1300
+ "hindsight.mentalModelRefreshIntervalMs": { type: "number", default: 5 * 60 * 1000 },
1301
+ "hindsight.mentalModelMaxRenderChars": { type: "number", default: 16_000 },
1302
+
1278
1303
  // TTSR
1279
1304
  "ttsr.enabled": {
1280
1305
  type: "boolean",
@@ -793,6 +793,16 @@ interface IndexedEdit {
793
793
  idx: number;
794
794
  }
795
795
 
796
+ type HashlineDeleteEdit = Extract<HashlineEdit, { kind: "delete" }>;
797
+
798
+ interface HashlineReplacementGroup {
799
+ startIndex: number;
800
+ endIndex: number;
801
+ sourceLineNum: number;
802
+ replacement: string[];
803
+ deletes: HashlineDeleteEdit[];
804
+ }
805
+
796
806
  function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
797
807
  if (edit.kind === "delete") return [edit.anchor];
798
808
  if (edit.kind === "modify") return [edit.anchor];
@@ -889,6 +899,183 @@ function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lin
889
899
  }
890
900
 
891
901
  /** Bucket edits by the line they target so we can apply each line's group in one splice. */
902
+
903
+ function getAnchorTargetLine(edit: HashlineEdit): number | undefined {
904
+ if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line;
905
+ if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
906
+ return undefined;
907
+ }
908
+
909
+ function collectAnchorTargetLines(edits: HashlineEdit[]): Set<number> {
910
+ const lines = new Set<number>();
911
+ for (const edit of edits) {
912
+ const line = getAnchorTargetLine(edit);
913
+ if (line !== undefined) lines.add(line);
914
+ }
915
+ return lines;
916
+ }
917
+
918
+ function findReplacementGroup(edits: HashlineEdit[], startIndex: number): HashlineReplacementGroup | undefined {
919
+ const first = edits[startIndex];
920
+ if (first?.kind !== "insert" || first.cursor.kind !== "before_anchor") return undefined;
921
+
922
+ const sourceLineNum = first.lineNum;
923
+ const replacement: string[] = [];
924
+ let index = startIndex;
925
+ while (index < edits.length) {
926
+ const edit = edits[index];
927
+ if (edit.kind !== "insert" || edit.lineNum !== sourceLineNum || edit.cursor.kind !== "before_anchor") break;
928
+ replacement.push(edit.text);
929
+ index++;
930
+ }
931
+
932
+ const deletes: HashlineDeleteEdit[] = [];
933
+ while (index < edits.length) {
934
+ const edit = edits[index];
935
+ if (edit.kind !== "delete" || edit.lineNum !== sourceLineNum) break;
936
+ deletes.push(edit);
937
+ index++;
938
+ }
939
+ if (deletes.length === 0) return undefined;
940
+
941
+ const startLine = deletes[0].anchor.line;
942
+ for (let offset = 0; offset < deletes.length; offset++) {
943
+ if (deletes[offset].anchor.line !== startLine + offset) return undefined;
944
+ }
945
+ const cursorLine = first.cursor.anchor.line;
946
+ if (cursorLine !== startLine) return undefined;
947
+
948
+ return { startIndex, endIndex: index - 1, sourceLineNum, replacement, deletes };
949
+ }
950
+
951
+ function countMatchingPrefixBlock(fileLines: string[], startLine: number, replacement: string[]): number {
952
+ const max = Math.min(replacement.length, startLine - 1);
953
+ for (let count = max; count >= 2; count--) {
954
+ let matches = true;
955
+ for (let offset = 0; offset < count; offset++) {
956
+ if (fileLines[startLine - count - 1 + offset] !== replacement[offset]) {
957
+ matches = false;
958
+ break;
959
+ }
960
+ }
961
+ if (matches) return count;
962
+ }
963
+ return 0;
964
+ }
965
+
966
+ function countMatchingSuffixBlock(fileLines: string[], endLine: number, replacement: string[]): number {
967
+ const max = Math.min(replacement.length, fileLines.length - endLine);
968
+ for (let count = max; count >= 2; count--) {
969
+ let matches = true;
970
+ for (let offset = 0; offset < count; offset++) {
971
+ if (fileLines[endLine + offset] !== replacement[replacement.length - count + offset]) {
972
+ matches = false;
973
+ break;
974
+ }
975
+ }
976
+ if (matches) return count;
977
+ }
978
+ return 0;
979
+ }
980
+
981
+ function hasExternalTargets(lines: Iterable<number>, externalTargetLines: Set<number>): boolean {
982
+ for (const line of lines) {
983
+ if (externalTargetLines.has(line)) return true;
984
+ }
985
+ return false;
986
+ }
987
+
988
+ function contiguousRange(start: number, count: number): number[] {
989
+ return Array.from({ length: count }, (_, offset) => start + offset);
990
+ }
991
+
992
+ function deleteEditForAutoAbsorbedLine(
993
+ line: number,
994
+ sourceLineNum: number,
995
+ index: number,
996
+ fileLines: string[],
997
+ ): HashlineEdit {
998
+ return {
999
+ kind: "delete",
1000
+ anchor: { line, hash: computeLineHash(line, fileLines[line - 1] ?? "") },
1001
+ lineNum: sourceLineNum,
1002
+ index,
1003
+ };
1004
+ }
1005
+
1006
+ function absorbReplacementBoundaryDuplicates(
1007
+ edits: HashlineEdit[],
1008
+ fileLines: string[],
1009
+ warnings: string[],
1010
+ ): HashlineEdit[] {
1011
+ let nextSyntheticIndex = edits.length;
1012
+ const absorbed: HashlineEdit[] = [];
1013
+
1014
+ // Anchor targets are stable across the loop because we only ever append
1015
+ // synthetic deletes (never mutate originals). A line in this set that
1016
+ // falls outside the current group's range is necessarily owned by another
1017
+ // op, so absorbing it would silently steal its target.
1018
+ const allTargetLines = collectAnchorTargetLines(edits);
1019
+ const emittedAbsorbKeys = new Set<string>();
1020
+
1021
+ for (let index = 0; index < edits.length; index++) {
1022
+ const group = findReplacementGroup(edits, index);
1023
+ if (!group) {
1024
+ absorbed.push(edits[index]);
1025
+ continue;
1026
+ }
1027
+
1028
+ const startLine = group.deletes[0].anchor.line;
1029
+ const endLine = group.deletes[group.deletes.length - 1].anchor.line;
1030
+
1031
+ const prefixCount = countMatchingPrefixBlock(fileLines, startLine, group.replacement);
1032
+ const suffixCount = countMatchingSuffixBlock(fileLines, endLine, group.replacement);
1033
+ const prefixLines = contiguousRange(startLine - prefixCount, prefixCount);
1034
+ const suffixLines = contiguousRange(endLine + 1, suffixCount);
1035
+ const safePrefixCount = hasExternalTargets(prefixLines, allTargetLines) ? 0 : prefixCount;
1036
+ const safeSuffixCount = hasExternalTargets(suffixLines, allTargetLines) ? 0 : suffixCount;
1037
+
1038
+ if (safePrefixCount > 0) {
1039
+ const absorbStart = startLine - safePrefixCount;
1040
+ const key = `prefix:${absorbStart}..${startLine - 1}`;
1041
+ if (!emittedAbsorbKeys.has(key)) {
1042
+ emittedAbsorbKeys.add(key);
1043
+ warnings.push(
1044
+ `Auto-absorbed ${safePrefixCount} duplicate line(s) above replacement at line ${group.sourceLineNum} ` +
1045
+ `(file lines ${absorbStart}..${startLine - 1} matched the payload's leading lines; ` +
1046
+ `widened the deletion to absorb them).`,
1047
+ );
1048
+ }
1049
+ }
1050
+ if (safeSuffixCount > 0) {
1051
+ const absorbEnd = endLine + safeSuffixCount;
1052
+ const key = `suffix:${endLine + 1}..${absorbEnd}`;
1053
+ if (!emittedAbsorbKeys.has(key)) {
1054
+ emittedAbsorbKeys.add(key);
1055
+ warnings.push(
1056
+ `Auto-absorbed ${safeSuffixCount} duplicate line(s) below replacement at line ${group.sourceLineNum} ` +
1057
+ `(file lines ${endLine + 1}..${absorbEnd} matched the payload's trailing lines; ` +
1058
+ `widened the deletion to absorb them).`,
1059
+ );
1060
+ }
1061
+ }
1062
+
1063
+ for (const line of contiguousRange(startLine - safePrefixCount, safePrefixCount)) {
1064
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
1065
+ }
1066
+ for (let groupIndex = group.startIndex; groupIndex <= group.endIndex; groupIndex++) {
1067
+ absorbed.push(edits[groupIndex]);
1068
+ }
1069
+ for (const line of contiguousRange(endLine + 1, safeSuffixCount)) {
1070
+ absorbed.push(deleteEditForAutoAbsorbedLine(line, group.sourceLineNum, nextSyntheticIndex++, fileLines));
1071
+ }
1072
+
1073
+ index = group.endIndex;
1074
+ }
1075
+
1076
+ return absorbed;
1077
+ }
1078
+
892
1079
  function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
893
1080
  const byLine = new Map<number, IndexedEdit[]>();
894
1081
  for (const entry of edits) {
@@ -922,10 +1109,12 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
922
1109
  const mismatches = validateHashlineAnchors(edits, fileLines, warnings);
923
1110
  if (mismatches.length > 0) throw new HashlineMismatchError(mismatches, fileLines);
924
1111
 
1112
+ const normalizedEdits = absorbReplacementBoundaryDuplicates(edits, fileLines, warnings);
1113
+
925
1114
  // Normalize after_anchor inserts to before_anchor of the next line, or EOF
926
1115
  // when the anchor is the final line. This keeps the bucketing logic below
927
1116
  // (which only knows about before_anchor / bof / eof) untouched.
928
- for (const edit of edits) {
1117
+ for (const edit of normalizedEdits) {
929
1118
  if (edit.kind !== "insert" || edit.cursor.kind !== "after_anchor") continue;
930
1119
  const anchorLine = edit.cursor.anchor.line;
931
1120
  if (anchorLine >= fileLines.length) {
@@ -944,7 +1133,7 @@ export function applyHashlineEdits(text: string, edits: HashlineEdit[]): Hashlin
944
1133
  const bofLines: string[] = [];
945
1134
  const eofLines: string[] = [];
946
1135
  const anchorEdits: IndexedEdit[] = [];
947
- edits.forEach((edit, idx) => {
1136
+ normalizedEdits.forEach((edit, idx) => {
948
1137
  if (edit.kind === "insert" && edit.cursor.kind === "bof") {
949
1138
  bofLines.push(edit.text);
950
1139
  } else if (edit.kind === "insert" && edit.cursor.kind === "eof") {