@gotgenes/pi-subagents 6.19.0 → 7.0.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/CHANGELOG.md +34 -0
- package/docs/architecture/architecture.md +5 -4
- package/docs/plans/0185-remove-persistent-agent-memory.md +161 -0
- package/docs/plans/0188-replace-any-casts-with-sdk-types.md +162 -0
- package/docs/retro/0172-extract-turn-formatting.md +40 -0
- package/docs/retro/0185-remove-persistent-agent-memory.md +39 -0
- package/docs/retro/0188-replace-any-casts-with-sdk-types.md +69 -0
- package/package.json +1 -1
- package/src/config/agent-types.ts +0 -20
- package/src/config/custom-agents.ts +1 -11
- package/src/index.ts +1 -3
- package/src/observation/record-observer.ts +8 -15
- package/src/session/context.ts +8 -3
- package/src/session/prompts.ts +1 -6
- package/src/session/safe-fs.ts +45 -0
- package/src/session/session-config.ts +3 -49
- package/src/session/skill-loader.ts +1 -1
- package/src/types.ts +10 -6
- package/src/ui/agent-config-editor.ts +0 -1
- package/src/ui/agent-creation-wizard.ts +0 -1
- package/src/ui/ui-observer.ts +3 -8
- package/src/session/memory.ts +0 -168
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [7.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.19.1...pi-subagents-v7.0.0) (2026-05-24)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* `src/session/memory.ts` and all persistent agent memory functionality (MEMORY.md, agent-memory directories) are removed from pi-subagents. This is scope reduction — agent spawning, execution, and result retrieval remain.
|
|
14
|
+
* `MemoryScope` is no longer exported from the package. The `memory` field is removed from `AgentConfig`. Custom agent .md files with a `memory:` frontmatter key will have it silently ignored.
|
|
15
|
+
* The `memory` field in agent configuration no longer has any effect. Memory block injection, memory tool augmentation, and the `AssemblerIO.buildMemoryBlock` / `buildReadOnlyMemoryBlock` collaborators are removed from the session assembler.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* delete memory module ([78ace55](https://github.com/gotgenes/pi-packages/commit/78ace558b1988bce8e002b28fe3152c5de708b84))
|
|
20
|
+
* remove memory from session assembly and config layers ([6ebeb91](https://github.com/gotgenes/pi-packages/commit/6ebeb91f28c56c1d21fc4e1a7b6bededa8eba025))
|
|
21
|
+
* remove MemoryScope type and memory config field ([d6e3bcb](https://github.com/gotgenes/pi-packages/commit/d6e3bcbb58902133a3704d89d88b548f8f2a4769))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Documentation
|
|
25
|
+
|
|
26
|
+
* plan remove persistent agent memory ([#185](https://github.com/gotgenes/pi-packages/issues/185)) ([0f6b3ad](https://github.com/gotgenes/pi-packages/commit/0f6b3adb6c35c3879c5d1e176e1c6d9da36a5cf1))
|
|
27
|
+
* **retro:** add planning stage notes for issue [#185](https://github.com/gotgenes/pi-packages/issues/185) ([58dcfbc](https://github.com/gotgenes/pi-packages/commit/58dcfbc3124ec7695048ad3df8fc6f397a883d1a))
|
|
28
|
+
* **retro:** add retro notes for issue [#188](https://github.com/gotgenes/pi-packages/issues/188) ([8eeaf6b](https://github.com/gotgenes/pi-packages/commit/8eeaf6b52f7b40a2126f3ffa3ca01a8e3b84f338))
|
|
29
|
+
* **retro:** add TDD stage notes for issue [#185](https://github.com/gotgenes/pi-packages/issues/185) ([8e75b18](https://github.com/gotgenes/pi-packages/commit/8e75b18b2c87b47f1a29df9d2c544a8ad2023f9f))
|
|
30
|
+
* update architecture after memory removal ([52716d5](https://github.com/gotgenes/pi-packages/commit/52716d5f89b729ce183d4a450d8539e6cabdbadc))
|
|
31
|
+
|
|
32
|
+
## [6.19.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.19.0...pi-subagents-v6.19.1) (2026-05-24)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### Documentation
|
|
36
|
+
|
|
37
|
+
* plan replace any casts with SDK types ([#188](https://github.com/gotgenes/pi-packages/issues/188)) ([96207da](https://github.com/gotgenes/pi-packages/commit/96207dacf0035db11605d55a61132cf43f7c3b40))
|
|
38
|
+
* **retro:** add planning stage notes for issue [#188](https://github.com/gotgenes/pi-packages/issues/188) ([6e38b12](https://github.com/gotgenes/pi-packages/commit/6e38b128a5bbaad3ca81b31adbf390482081a41e))
|
|
39
|
+
* **retro:** add retro notes for issue [#172](https://github.com/gotgenes/pi-packages/issues/172) ([270c00a](https://github.com/gotgenes/pi-packages/commit/270c00a5f84bf454352443a6c57a6076803090c6))
|
|
40
|
+
* **retro:** add TDD stage notes for issue [#188](https://github.com/gotgenes/pi-packages/issues/188) ([8a5f51a](https://github.com/gotgenes/pi-packages/commit/8a5f51a2fd02a143e85f176417b31af4a11b34f4))
|
|
41
|
+
|
|
8
42
|
## [6.19.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.18.8...pi-subagents-v6.19.0) (2026-05-24)
|
|
9
43
|
|
|
10
44
|
|
|
@@ -42,7 +42,7 @@ flowchart TB
|
|
|
42
42
|
SessionConfig["assembleSessionConfig\n(pure assembler)"]
|
|
43
43
|
Prompts["prompts\n(system prompt)"]
|
|
44
44
|
Context["context\n(parent history)"]
|
|
45
|
-
|
|
45
|
+
SafeFs["safe-fs\n(symlink/name guards)"]
|
|
46
46
|
SkillLoader["skill-loader\n(preload skills)"]
|
|
47
47
|
Env["env\n(git/platform)"]
|
|
48
48
|
ModelResolver["model-resolver\n(fuzzy match)"]
|
|
@@ -86,7 +86,8 @@ flowchart TB
|
|
|
86
86
|
AgentManager --> AgentRunner
|
|
87
87
|
AgentRunner --> SessionConfig
|
|
88
88
|
SessionConfig --> AgentTypeRegistry
|
|
89
|
-
SessionConfig --> Prompts &
|
|
89
|
+
SessionConfig --> Prompts & SkillLoader & Env
|
|
90
|
+
SkillLoader --> SafeFs
|
|
90
91
|
AgentTypeRegistry --> DefaultAgents & CustomAgents
|
|
91
92
|
RecordObserver -.->|subscribes| AgentRunner
|
|
92
93
|
UIObserver -.->|subscribes| AgentRunner
|
|
@@ -246,7 +247,7 @@ src/
|
|
|
246
247
|
│ ├── prompts.ts system prompt building
|
|
247
248
|
│ ├── content-items.ts shared message content parsing (tool-call names, assistant content)
|
|
248
249
|
│ ├── context.ts parent conversation extraction
|
|
249
|
-
│ ├──
|
|
250
|
+
│ ├── safe-fs.ts symlink rejection and safe file reads
|
|
250
251
|
│ ├── skill-loader.ts skill preloading
|
|
251
252
|
│ ├── env.ts git/platform detection
|
|
252
253
|
│ ├── model-resolver.ts fuzzy model name resolution
|
|
@@ -338,7 +339,7 @@ They declare this package as an optional peer dependency and use dynamic import
|
|
|
338
339
|
- `ParentSnapshot` — immutable snapshot of parent session state, captured once at spawn time.
|
|
339
340
|
- `record-observer` — session-event observer that updates record statistics without callback threading.
|
|
340
341
|
- Agent type registry — default agents, custom `.md` file loading.
|
|
341
|
-
- Prompt assembly, context extraction,
|
|
342
|
+
- Prompt assembly, context extraction, skills, environment.
|
|
342
343
|
- Worktree isolation.
|
|
343
344
|
- Token usage tracking.
|
|
344
345
|
- Session directory derivation and persisted `SessionManager` for subagent transcripts.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 185
|
|
3
|
+
issue_title: "pi-subagents: Remove persistent agent memory feature"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Remove persistent agent memory feature
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
The `memory.ts` module and all supporting code for persistent agent memory (`MEMORY.md`, `agent-memory/` directories) should be removed from `pi-subagents`.
|
|
11
|
+
The memory feature invents its own filesystem layout, file format, and security model — all outside the stated scope of `pi-subagents`, which is agent spawning, execution, and result retrieval.
|
|
12
|
+
This follows the same scope-reduction rationale as the scheduling subsystem removal (issue #52).
|
|
13
|
+
|
|
14
|
+
## Goals
|
|
15
|
+
|
|
16
|
+
- Remove the `memory.ts` module and all memory-related code from the package.
|
|
17
|
+
- Remove `MemoryScope` type and `memory` field from `AgentConfig`.
|
|
18
|
+
- Remove memory block injection from session assembly.
|
|
19
|
+
- Remove memory tool augmentation from agent-types.
|
|
20
|
+
- Remove memory parsing from custom agent loading.
|
|
21
|
+
- Remove memory display from UI components.
|
|
22
|
+
- Extract `isSymlink`, `isUnsafeName`, and `safeReadFile` to a shared utility module — `skill-loader.ts` depends on them independently of memory.
|
|
23
|
+
- Update architecture documentation to reflect the removal.
|
|
24
|
+
|
|
25
|
+
## Non-Goals
|
|
26
|
+
|
|
27
|
+
- Replacing memory with an alternative persistence mechanism — that is out of scope.
|
|
28
|
+
- Changing the `skill-loader.ts` logic beyond updating the import source for the extracted utilities.
|
|
29
|
+
|
|
30
|
+
## Background
|
|
31
|
+
|
|
32
|
+
The memory system spans six source modules and two UI modules:
|
|
33
|
+
|
|
34
|
+
| File | Memory surface |
|
|
35
|
+
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| `src/session/memory.ts` | Core module: `buildMemoryBlock`, `buildReadOnlyMemoryBlock`, `resolveMemoryDir`, `ensureMemoryDir`, `readMemoryIndex`, plus shared utilities `isSymlink`, `isUnsafeName`, `safeReadFile` |
|
|
37
|
+
| `src/types.ts` | `MemoryScope` type, `memory?: MemoryScope` field on `AgentConfig` |
|
|
38
|
+
| `src/session/session-config.ts` | `AssemblerIO.buildMemoryBlock` / `buildReadOnlyMemoryBlock` fields, memory logic block (~25 lines), `MemoryScope` import |
|
|
39
|
+
| `src/config/agent-types.ts` | `getMemoryToolNames()`, `getReadOnlyMemoryToolNames()`, `MEMORY_TOOL_NAMES`, `READONLY_MEMORY_TOOL_NAMES` |
|
|
40
|
+
| `src/config/custom-agents.ts` | `parseMemory()` function, `memory: parseMemory(fm.memory)` assignment |
|
|
41
|
+
| `src/session/prompts.ts` | `memoryBlock` field on `PromptExtras`, injection into system prompt |
|
|
42
|
+
| `src/ui/agent-config-editor.ts` | `if (cfg.memory) fmFields.push(...)` |
|
|
43
|
+
| `src/ui/agent-creation-wizard.ts` | `memory:` line in frontmatter help text |
|
|
44
|
+
| `src/index.ts` | Imports `buildMemoryBlock`, `buildReadOnlyMemoryBlock`; wires them into `assemblerIO` |
|
|
45
|
+
|
|
46
|
+
The three utility functions (`isSymlink`, `isUnsafeName`, `safeReadFile`) are imported by `skill-loader.ts` for filesystem safety — they are not memory-specific and must survive the removal.
|
|
47
|
+
|
|
48
|
+
AGENTS.md constraint: the architecture doc in `docs/architecture/architecture.md` references `memory.ts` in the module listing and domain model diagram — both need updating.
|
|
49
|
+
|
|
50
|
+
## Design Overview
|
|
51
|
+
|
|
52
|
+
This is a pure removal with one extraction.
|
|
53
|
+
The only design decision is where to place the extracted utilities.
|
|
54
|
+
|
|
55
|
+
The three functions (`isSymlink`, `isUnsafeName`, `safeReadFile`) are filesystem safety primitives used by `skill-loader.ts`.
|
|
56
|
+
They belong in a new `src/session/safe-fs.ts` module in the session domain, co-located with their sole remaining consumer.
|
|
57
|
+
|
|
58
|
+
The removal works consumers-first, declaration-last:
|
|
59
|
+
|
|
60
|
+
1. Extract shared utilities to `safe-fs.ts` (additive, no behavior change).
|
|
61
|
+
2. Remove all memory consumers — session-config, prompts, agent-types, custom-agents, UI, index wiring.
|
|
62
|
+
3. Remove the declarations — `MemoryScope`, `AgentConfig.memory`, `memory.ts`.
|
|
63
|
+
4. Update architecture docs.
|
|
64
|
+
|
|
65
|
+
## Module-Level Changes
|
|
66
|
+
|
|
67
|
+
### New files
|
|
68
|
+
|
|
69
|
+
- `src/session/safe-fs.ts` — extracted `isSymlink`, `isUnsafeName`, `safeReadFile`.
|
|
70
|
+
- `test/session/safe-fs.test.ts` — tests moved from `memory.test.ts` for these three functions.
|
|
71
|
+
|
|
72
|
+
### Modified files
|
|
73
|
+
|
|
74
|
+
- `src/session/skill-loader.ts` — change import from `#src/session/memory` to `#src/session/safe-fs`.
|
|
75
|
+
- `src/session/session-config.ts` — remove `buildMemoryBlock` / `buildReadOnlyMemoryBlock` from `AssemblerIO`; remove `MemoryScope` import; remove entire memory logic block (~lines 215–242).
|
|
76
|
+
- `src/session/prompts.ts` — remove `memoryBlock` from `PromptExtras` interface; remove `extras?.memoryBlock` injection.
|
|
77
|
+
- `src/config/agent-types.ts` — remove `MEMORY_TOOL_NAMES`, `READONLY_MEMORY_TOOL_NAMES`, `getMemoryToolNames()`, `getReadOnlyMemoryToolNames()`.
|
|
78
|
+
- `src/config/custom-agents.ts` — remove `MemoryScope` import; remove `memory: parseMemory(fm.memory)` assignment; remove `parseMemory()` function.
|
|
79
|
+
- `src/types.ts` — remove `MemoryScope` type; remove `memory?: MemoryScope` from `AgentConfig`; remove associated doc comment.
|
|
80
|
+
- `src/ui/agent-config-editor.ts` — remove `if (cfg.memory)` line.
|
|
81
|
+
- `src/ui/agent-creation-wizard.ts` — remove `memory:` line from frontmatter help text.
|
|
82
|
+
- `src/index.ts` — remove `buildMemoryBlock` / `buildReadOnlyMemoryBlock` import; remove those fields from `assemblerIO` object.
|
|
83
|
+
- `docs/architecture/architecture.md` — remove `memory.ts` from module listing; remove Memory node from domain model Mermaid diagram; update session domain description.
|
|
84
|
+
|
|
85
|
+
### Deleted files
|
|
86
|
+
|
|
87
|
+
- `src/session/memory.ts` — entire module.
|
|
88
|
+
- `test/session/memory.test.ts` — entire test file (tests for `isSymlink`, `isUnsafeName`, `safeReadFile` are moved to `safe-fs.test.ts` first; remaining memory-specific tests are deleted).
|
|
89
|
+
|
|
90
|
+
### Test files modified
|
|
91
|
+
|
|
92
|
+
- `test/session/session-config.test.ts` — remove `mockBuildMemoryBlock` / `mockBuildReadOnlyMemoryBlock` mocks from `AssemblerIO` construction; remove "assembleSessionConfig — memory block selection" describe block (~lines 354–427).
|
|
93
|
+
- `test/session/prompts.test.ts` — remove "injects memory block in replace mode", "injects memory block in append mode", and "injects both memory and skills" test cases.
|
|
94
|
+
- `test/config/agent-types.test.ts` — remove `getMemoryToolNames` / `getReadOnlyMemoryToolNames` imports and test suite (~lines 26–51).
|
|
95
|
+
- `test/config/custom-agents.test.ts` — remove memory scope parsing tests (~lines 361–403).
|
|
96
|
+
|
|
97
|
+
## Test Impact Analysis
|
|
98
|
+
|
|
99
|
+
1. The extraction of `isSymlink`, `isUnsafeName`, `safeReadFile` to `safe-fs.ts` enables their tests to exist independently of the memory module — currently they are co-located with memory-specific tests in `memory.test.ts`.
|
|
100
|
+
2. The memory-specific tests in `memory.test.ts` (`resolveMemoryDir`, `ensureMemoryDir`, `readMemoryIndex`, `buildMemoryBlock`, `buildReadOnlyMemoryBlock`) become redundant and are deleted — the code they test is being removed.
|
|
101
|
+
3. The memory block selection tests in `session-config.test.ts` (~70 lines) test the memory branching logic in `assembleSessionConfig` — they are deleted because that logic is removed.
|
|
102
|
+
4. The memory injection tests in `prompts.test.ts` test `memoryBlock` injection — deleted because the field and injection code are removed.
|
|
103
|
+
5. The memory parsing tests in `custom-agents.test.ts` test `parseMemory` — deleted because the function is removed.
|
|
104
|
+
6. The memory tool name helper tests in `agent-types.test.ts` test `getMemoryToolNames` / `getReadOnlyMemoryToolNames` — deleted because the functions are removed.
|
|
105
|
+
7. All other tests remain as-is — they do not depend on memory functionality.
|
|
106
|
+
|
|
107
|
+
## TDD Order
|
|
108
|
+
|
|
109
|
+
1. **Extract utilities to `safe-fs.ts`.**
|
|
110
|
+
Create `src/session/safe-fs.ts` with `isSymlink`, `isUnsafeName`, `safeReadFile`.
|
|
111
|
+
Create `test/session/safe-fs.test.ts` with tests moved from `memory.test.ts` for these three functions.
|
|
112
|
+
Update `src/session/skill-loader.ts` import to point to `#src/session/safe-fs`.
|
|
113
|
+
Update `src/session/memory.ts` import to point to `#src/session/safe-fs` (temporary — keeps memory working until removal).
|
|
114
|
+
Verify: `pnpm vitest run` and `pnpm run check`.
|
|
115
|
+
Commit: `refactor: extract safe-fs utilities from memory module`
|
|
116
|
+
|
|
117
|
+
2. **Remove memory from session assembly and config layers.**
|
|
118
|
+
Remove `buildMemoryBlock` / `buildReadOnlyMemoryBlock` from `AssemblerIO` in `session-config.ts`.
|
|
119
|
+
Remove `MemoryScope` import and all memory logic from `session-config.ts`.
|
|
120
|
+
Remove `memoryBlock` from `PromptExtras` in `prompts.ts` and its injection logic.
|
|
121
|
+
Remove `getMemoryToolNames`, `getReadOnlyMemoryToolNames`, `MEMORY_TOOL_NAMES`, `READONLY_MEMORY_TOOL_NAMES` from `agent-types.ts`.
|
|
122
|
+
Remove `getMemoryToolNames` / `getReadOnlyMemoryToolNames` import from `session-config.ts`.
|
|
123
|
+
Remove `buildMemoryBlock` / `buildReadOnlyMemoryBlock` import and `assemblerIO` fields from `index.ts`.
|
|
124
|
+
Update `test/session/session-config.test.ts`: remove memory mocks from IO construction and memory block selection test suite.
|
|
125
|
+
Update `test/session/prompts.test.ts`: remove memory injection tests.
|
|
126
|
+
Update `test/config/agent-types.test.ts`: remove memory tool name helper tests.
|
|
127
|
+
Verify: `pnpm vitest run` and `pnpm run check`.
|
|
128
|
+
Commit: `feat!: remove memory from session assembly and config layers`
|
|
129
|
+
|
|
130
|
+
3. **Remove memory from types, custom-agents, and UI.**
|
|
131
|
+
Remove `MemoryScope` type and `memory` field from `AgentConfig` in `types.ts`.
|
|
132
|
+
Remove `parseMemory()` function, `MemoryScope` import, and `memory:` assignment from `custom-agents.ts`.
|
|
133
|
+
Remove `if (cfg.memory)` line from `agent-config-editor.ts`.
|
|
134
|
+
Remove `memory:` help text line from `agent-creation-wizard.ts`.
|
|
135
|
+
Update `test/config/custom-agents.test.ts`: remove memory parsing tests.
|
|
136
|
+
Verify: `pnpm vitest run` and `pnpm run check`.
|
|
137
|
+
Commit: `feat!: remove MemoryScope type and memory config field`
|
|
138
|
+
|
|
139
|
+
4. **Delete `memory.ts` and its test file.**
|
|
140
|
+
Delete `src/session/memory.ts`.
|
|
141
|
+
Delete `test/session/memory.test.ts`.
|
|
142
|
+
Verify: `pnpm vitest run` and `pnpm run check`.
|
|
143
|
+
Commit: `feat!: delete memory module`
|
|
144
|
+
|
|
145
|
+
5. **Update architecture documentation.**
|
|
146
|
+
Remove Memory node from the domain model Mermaid diagram.
|
|
147
|
+
Remove `memory.ts` from the module listing.
|
|
148
|
+
Update session domain description.
|
|
149
|
+
Commit: `docs: update architecture after memory removal`
|
|
150
|
+
|
|
151
|
+
## Risks and Mitigations
|
|
152
|
+
|
|
153
|
+
| Risk | Mitigation |
|
|
154
|
+
| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
155
|
+
| Users with `memory:` in custom agent frontmatter — the field silently becomes a no-op. | `parseMemory` removal means unknown frontmatter keys are ignored by the markdown parser. No error, no crash — just no memory. This is acceptable for a feature removal. |
|
|
156
|
+
| `skill-loader.ts` breaks if the utility extraction has a typo or missing re-export. | Step 1 runs full test suite before proceeding. The skill-loader tests exercise `isUnsafeName` and `safeReadFile` indirectly. |
|
|
157
|
+
| Architecture doc Mermaid diagram breaks after node removal. | Verify the diagram renders correctly after editing — remove both the node and all edges referencing it. |
|
|
158
|
+
|
|
159
|
+
## Open Questions
|
|
160
|
+
|
|
161
|
+
None — the issue scope is unambiguous and all affected files have been traced.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 188
|
|
3
|
+
issue_title: "refactor(pi-subagents): replace any casts with SDK types in extractText and SubscribableSession"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Replace `any` casts with SDK types
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
Two places in pi-subagents use `any` where proper SDK types are available and already imported in adjacent files.
|
|
11
|
+
`extractText` in `session/context.ts` uses `(c: any)` in a filter/map chain, requiring a top-level `eslint-disable` for `no-unsafe-member-access` and `no-unsafe-return`.
|
|
12
|
+
`record-observer.ts` and `ui-observer.ts` each define an identical local `SubscribableSession` interface with `(event: any) => void`, creating both a type hole and duplicated boilerplate.
|
|
13
|
+
|
|
14
|
+
## Goals
|
|
15
|
+
|
|
16
|
+
- Replace `any` casts in `extractText` with a `TextContent` type predicate.
|
|
17
|
+
- Remove the `eslint-disable` comment from `session/context.ts`.
|
|
18
|
+
- Replace `any` in the `SubscribableSession` interface with `AgentSessionEvent`.
|
|
19
|
+
- Deduplicate the `SubscribableSession` interface into a single shared definition.
|
|
20
|
+
|
|
21
|
+
## Non-Goals
|
|
22
|
+
|
|
23
|
+
- Changing the `extractText` parameter type from `unknown[]` — callers in `message-formatters.ts` pass `unknown[]`, and widening the refactoring surface is out of scope.
|
|
24
|
+
- Replacing the `SubscribableSession` interface with the full `AgentSession` class — ISP requires a narrow interface (the observers only need `subscribe`).
|
|
25
|
+
- Addressing the `eslint-disable` in `record-observer.ts` and `ui-observer.ts` for `no-unsafe-member-access` / `no-unsafe-assignment` — those are caused by the `event` property access pattern inside the callback body, not by the parameter type.
|
|
26
|
+
Once the callback parameter is typed as `AgentSessionEvent`, the unsafe-access rules should be satisfied and those `eslint-disable` comments can be removed too.
|
|
27
|
+
|
|
28
|
+
## Background
|
|
29
|
+
|
|
30
|
+
### Existing conventions
|
|
31
|
+
|
|
32
|
+
`content-items.ts` already imports `TextContent` from `@earendil-works/pi-ai` and uses `(c as TextContent).text` after a `c.type === "text"` guard.
|
|
33
|
+
`agent-runner.ts` already imports `AgentSessionEvent` from `@earendil-works/pi-coding-agent` and uses it as the parameter type in `session.subscribe((event: AgentSessionEvent) => { ... })`.
|
|
34
|
+
Both SDK types are proven to work in this package.
|
|
35
|
+
|
|
36
|
+
### `extractText` callers
|
|
37
|
+
|
|
38
|
+
`extractText(content: unknown[])` is called from:
|
|
39
|
+
|
|
40
|
+
- `session/context.ts` — `buildParentContext` passes `msg.content` from session entries.
|
|
41
|
+
- `lifecycle/agent-runner.ts` — `getLastAssistantText` and `getAgentConversation` pass `msg.content`.
|
|
42
|
+
- `ui/message-formatters.ts` — `formatUserMessage` and `formatToolResult` pass `unknown[]` content.
|
|
43
|
+
|
|
44
|
+
The parameter type stays `unknown[]` to avoid rippling through callers.
|
|
45
|
+
The type predicate narrows inside the function body.
|
|
46
|
+
|
|
47
|
+
### `SubscribableSession` consumers
|
|
48
|
+
|
|
49
|
+
Both `subscribeRecordObserver` and `subscribeUIObserver` accept a `SubscribableSession` parameter.
|
|
50
|
+
Tests use `createMockSession()` from `test/helpers/mock-session.ts`, which returns a `MockSession` with `subscribe: Mock<(fn: (event: unknown) => void) => () => void>`.
|
|
51
|
+
|
|
52
|
+
Changing `SubscribableSession.subscribe` to accept `(event: AgentSessionEvent) => void` is structurally sound: the mock's `subscribe` accepting `(fn: (event: unknown) => void)` is a supertype — a function that accepts any event can accept an `AgentSessionEvent`.
|
|
53
|
+
The TypeScript compiler allows this because of function parameter contravariance.
|
|
54
|
+
Tests construct inline event objects that match `AgentSessionEvent` member shapes, so no test changes are needed.
|
|
55
|
+
|
|
56
|
+
### Shared location for `SubscribableSession`
|
|
57
|
+
|
|
58
|
+
The interface is used by two domains (observation, UI).
|
|
59
|
+
A new shared types location is needed.
|
|
60
|
+
The existing `types.ts` at the package root contains cross-cutting types (`SubagentType`, `ThinkingLevel`, `ShellExec`).
|
|
61
|
+
`SubscribableSession` fits there — it's a narrow cross-domain interface for session event subscription.
|
|
62
|
+
|
|
63
|
+
## Design Overview
|
|
64
|
+
|
|
65
|
+
### `extractText` type predicate
|
|
66
|
+
|
|
67
|
+
Replace the `any` casts with a user-defined type guard:
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import type { TextContent } from "@earendil-works/pi-ai";
|
|
71
|
+
|
|
72
|
+
function isTextContent(c: unknown): c is TextContent {
|
|
73
|
+
return typeof c === "object" && c !== null && (c as { type: string }).type === "text";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function extractText(content: unknown[]): string {
|
|
77
|
+
return content
|
|
78
|
+
.filter(isTextContent)
|
|
79
|
+
.map((c) => c.text ?? "")
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The type predicate eliminates both `any` casts and the `eslint-disable` at the top of the file.
|
|
85
|
+
|
|
86
|
+
### `SubscribableSession` with `AgentSessionEvent`
|
|
87
|
+
|
|
88
|
+
Move the interface to `types.ts` and type the callback:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
92
|
+
|
|
93
|
+
export interface SubscribableSession {
|
|
94
|
+
subscribe(fn: (event: AgentSessionEvent) => void): () => void;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Both observer files import from `types.ts` instead of defining their own.
|
|
99
|
+
|
|
100
|
+
### Event property access in observer callbacks
|
|
101
|
+
|
|
102
|
+
Once the callback parameter is typed as `AgentSessionEvent`, TypeScript knows the event's discriminated union members.
|
|
103
|
+
The `event.type` checks narrow the union, so `event.toolName`, `event.message`, etc. become type-safe.
|
|
104
|
+
The `eslint-disable` comments for `no-unsafe-member-access` and `no-unsafe-assignment` can be removed from both observer files.
|
|
105
|
+
|
|
106
|
+
## Module-Level Changes
|
|
107
|
+
|
|
108
|
+
| File | Change |
|
|
109
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `src/session/context.ts` | Import `TextContent`; add `isTextContent` type predicate; replace `any` filter/map; remove top-level `eslint-disable` |
|
|
111
|
+
| `src/types.ts` | Add `SubscribableSession` interface with `AgentSessionEvent` callback type; add `AgentSessionEvent` import |
|
|
112
|
+
| `src/observation/record-observer.ts` | Import `SubscribableSession` from `types.ts`; remove local interface; remove top-level `eslint-disable`; remove inline `any` annotation on callback parameter |
|
|
113
|
+
| `src/ui/ui-observer.ts` | Import `SubscribableSession` from `types.ts`; remove local interface; remove top-level `eslint-disable`; remove inline `any` annotation on callback parameter |
|
|
114
|
+
|
|
115
|
+
No test file changes expected — the mock session's structural typing remains compatible.
|
|
116
|
+
|
|
117
|
+
## Test Impact Analysis
|
|
118
|
+
|
|
119
|
+
1. No new unit tests are needed — the refactoring is type-only (no behavioral change).
|
|
120
|
+
2. No existing tests become redundant.
|
|
121
|
+
3. All existing tests for `subscribeRecordObserver` and `subscribeUIObserver` must pass as-is — they verify the same event-handling behavior.
|
|
122
|
+
|
|
123
|
+
## TDD Order
|
|
124
|
+
|
|
125
|
+
This is a pure refactoring with no behavioral change.
|
|
126
|
+
Each step should pass `pnpm run check` (type-check) and `pnpm vitest run` (tests) before committing.
|
|
127
|
+
|
|
128
|
+
1. **Add `isTextContent` type predicate and remove `any` from `extractText`.**
|
|
129
|
+
Import `TextContent` from `@earendil-works/pi-ai`.
|
|
130
|
+
Add `isTextContent` predicate function.
|
|
131
|
+
Replace the `any`-cast filter/map chain with the predicate.
|
|
132
|
+
Remove the top-level `eslint-disable` comment.
|
|
133
|
+
Verify: `pnpm run check`, `pnpm vitest run`.
|
|
134
|
+
Commit: `refactor: replace any casts in extractText with TextContent type predicate (#188)`
|
|
135
|
+
|
|
136
|
+
2. **Move `SubscribableSession` to `types.ts` with `AgentSessionEvent`.**
|
|
137
|
+
Add `AgentSessionEvent` import and `SubscribableSession` interface to `src/types.ts`.
|
|
138
|
+
Update `record-observer.ts`: import from `types.ts`, remove local interface, remove `eslint-disable`, remove `any` from callback parameter.
|
|
139
|
+
Update `ui-observer.ts`: import from `types.ts`, remove local interface, remove `eslint-disable`, remove `any` from callback parameter.
|
|
140
|
+
Verify: `pnpm run check`, `pnpm vitest run`.
|
|
141
|
+
Commit: `refactor: replace any in SubscribableSession with AgentSessionEvent (#188)`
|
|
142
|
+
|
|
143
|
+
## Risks and Mitigations
|
|
144
|
+
|
|
145
|
+
1. **`AgentSessionEvent` union may not cover all event shapes accessed in observers.**
|
|
146
|
+
Mitigation: `agent-runner.ts` already uses the same type for identical event patterns (`event.type`, `event.toolName`, `event.message`).
|
|
147
|
+
The type checker will flag any property access that the union doesn't support.
|
|
148
|
+
Run `pnpm run check` after each step.
|
|
149
|
+
|
|
150
|
+
2. **Mock session type incompatibility.**
|
|
151
|
+
The mock's `subscribe` accepts `(fn: (event: unknown) => void)`.
|
|
152
|
+
A `SubscribableSession` with `(fn: (event: AgentSessionEvent) => void)` is structurally compatible via contravariance.
|
|
153
|
+
If the compiler disagrees, the mitigation is to update `MockSession.subscribe` to accept `(fn: (event: AgentSessionEvent) => void)` — a one-line change.
|
|
154
|
+
|
|
155
|
+
3. **`TextContent.text` is non-optional in the SDK type.**
|
|
156
|
+
The current code uses `c.text ?? ""` which implies `text` could be undefined.
|
|
157
|
+
`TextContent` defines `text: string` (required), so the nullish coalescing is harmless but unnecessary.
|
|
158
|
+
Keep it for safety — removing it is a separate cleanup.
|
|
159
|
+
|
|
160
|
+
## Open Questions
|
|
161
|
+
|
|
162
|
+
None — the issue's proposed approach is unambiguous and the SDK types are already validated in adjacent files.
|
|
@@ -38,3 +38,43 @@ Test count went from 896 to 907 (+11).
|
|
|
38
38
|
- `message-formatters.ts` had both an import and a re-export of `getToolCallName`; simplified to a pure re-export only.
|
|
39
39
|
- The lint fixup (unused import) was amended into the same refactor commit before pushing.
|
|
40
40
|
- Architecture doc updated: `content-items.ts` added to session module listing, production-duplication section updated, Step 9 marked Done.
|
|
41
|
+
|
|
42
|
+
## Stage: Final Retrospective (2026-05-24T20:30:00Z)
|
|
43
|
+
|
|
44
|
+
### Session summary
|
|
45
|
+
|
|
46
|
+
Planned, implemented, and shipped the extraction of shared turn-formatting logic from `lifecycle/agent-runner.ts` and `ui/message-formatters.ts` into `session/content-items.ts`.
|
|
47
|
+
Released as `pi-subagents-v6.19.0`.
|
|
48
|
+
During code review the user challenged double-casts in the initial implementation, which led to discovering that the local `ToolCallContent` type was dead code and the SDK exports the real `ToolCall` type — the final implementation is significantly cleaner than what the plan specified.
|
|
49
|
+
Filed #188 for broader `any`-to-SDK-type cleanup discovered during the investigation.
|
|
50
|
+
|
|
51
|
+
### Observations
|
|
52
|
+
|
|
53
|
+
#### What went well
|
|
54
|
+
|
|
55
|
+
- The user's Socratic challenge ("Talk to me about these double-casts") was the pivotal moment.
|
|
56
|
+
Rather than directing a fix, it prompted an investigation of the SDK's actual `ToolCall` type, which revealed that `ToolCall.name` is always required and `toolName` never appears on content items.
|
|
57
|
+
This eliminated the `ToolCallContent` interface, the `toolName` fallback, the index-signature parameter type, and all double-casts — none of which the plan anticipated.
|
|
58
|
+
- Cross-session retro context worked well: the planning-stage note about #170 shifting the duplication target saved time during TDD.
|
|
59
|
+
- The SDK source investigation yielded a follow-up issue (#188) for replacing `any` casts in `extractText` and `SubscribableSession` with proper SDK types.
|
|
60
|
+
|
|
61
|
+
#### What caused friction (agent side)
|
|
62
|
+
|
|
63
|
+
- `missing-context` — Did not check SDK type exports during planning.
|
|
64
|
+
The plan copied `ToolCallContent` verbatim from the existing code without verifying what `@earendil-works/pi-ai` exports.
|
|
65
|
+
The source comments ("SDK doesn't export the narrow type") were wrong — the types have been exported for some time.
|
|
66
|
+
Impact: the initial TDD implementation introduced a `{ type: string; [key: string]: unknown }` parameter type that forced `as unknown as` double-casts, requiring a full rework after user review.
|
|
67
|
+
- `premature-convergence` — When TypeScript rejected excess properties in test object literals, I widened the parameter type to include an index signature instead of exploring alternatives.
|
|
68
|
+
The correct fix (using `ReadonlyArray<{ type: string }>` with `in` narrowing, or importing SDK types as test fixtures) was simpler and avoided the cast cascade.
|
|
69
|
+
Impact: one round of rework plus an amended commit that muddied the git history.
|
|
70
|
+
|
|
71
|
+
#### What caused friction (user side)
|
|
72
|
+
|
|
73
|
+
- The user's intervention at the cast review stage was well-timed and effective.
|
|
74
|
+
One earlier opportunity: if the user had flagged the `toolName` fallback or the SDK-type question during the plan review (before TDD started), the initial implementation would have been correct from the start.
|
|
75
|
+
However, this is a marginal improvement — the plan review was clean and the friction was minor.
|
|
76
|
+
|
|
77
|
+
### Changes made
|
|
78
|
+
|
|
79
|
+
1. `.pi/skills/code-design/SKILL.md` — Added two rules to "Pi SDK boundaries": verify SDK exports before redeclaring types locally; prefer minimal structural supertypes over index-signature types for parameters accepting SDK content.
|
|
80
|
+
2. `.pi/skills/testing/SKILL.md` — Added TDD planning rule: verify SDK exports when extracting locally-declared types that shadow SDK types.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 185
|
|
3
|
+
issue_title: "pi-subagents: Remove persistent agent memory feature"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #185 — pi-subagents: Remove persistent agent memory feature
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-24T20:46:56Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Traced all memory-related code across 9 source files, 5 test files, and the architecture doc.
|
|
13
|
+
Produced a 5-step TDD plan: extract shared utilities (`isSymlink`, `isUnsafeName`, `safeReadFile`) to `safe-fs.ts`, then remove memory consumers (session assembly, config, UI), then delete the module, then update docs.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- The three utility functions in `memory.ts` are the only complication — `skill-loader.ts` imports them independently of memory.
|
|
18
|
+
Extracting to `src/session/safe-fs.ts` keeps them co-located with their sole remaining consumer.
|
|
19
|
+
- The removal is consumers-first, declaration-last: session-config and prompts lose their memory logic before `MemoryScope` is removed from `types.ts`, avoiding intermediate type errors.
|
|
20
|
+
- No ambiguous design choices — the issue scope section is precise about what to remove and what to extract.
|
|
21
|
+
- Memory field in custom agent frontmatter will silently become a no-op (ignored by the YAML parser) — no user-facing error, just loss of the feature.
|
|
22
|
+
- The `AssemblerIO` interface shrinks from 4 fields to 2 after removal, which is a welcome simplification.
|
|
23
|
+
|
|
24
|
+
## Stage: Implementation — TDD (2026-05-24T22:41:23Z)
|
|
25
|
+
|
|
26
|
+
### Session summary
|
|
27
|
+
|
|
28
|
+
All 5 TDD steps completed across 5 commits.
|
|
29
|
+
Test count went from 901 (54 files) to 848 (53 files) — a net reduction of 53 tests and 1 file, reflecting the deletion of the `memory.test.ts` file with its memory-specific tests and the removal of memory-related tests from `session-config.test.ts`, `prompts.test.ts`, `agent-types.test.ts`, and `custom-agents.test.ts`.
|
|
30
|
+
New file `safe-fs.test.ts` was created with 13 tests for the extracted utilities.
|
|
31
|
+
|
|
32
|
+
### Observations
|
|
33
|
+
|
|
34
|
+
- Step 1 had a subtle bug: after re-exporting `isUnsafeName` from `safe-fs` in `memory.ts`, the function was not imported into the `memory.ts` module scope itself, so `resolveMemoryDir` got a `ReferenceError` at runtime.
|
|
35
|
+
Fix was trivial: add `isUnsafeName` to the import alongside `isSymlink` and `safeReadFile`.
|
|
36
|
+
- Step 3 introduced a type error in `memory.ts` (still alive at that point): `MemoryScope` was imported from `#src/types` which no longer exported it.
|
|
37
|
+
Fix: inline the literal union `"user" | "project" | "local"` directly in `memory.ts` as a local type, so it compiles cleanly until deletion in step 4.
|
|
38
|
+
- The `SKILL.md` for `package-pi-subagents` also listed `memory.ts` in the session domain table — updated alongside `architecture.md` in the docs commit.
|
|
39
|
+
- No deviations from the plan other than the two minor bugs above (both self-corrected within the same TDD step).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 188
|
|
3
|
+
issue_title: "refactor(pi-subagents): replace any casts with SDK types in extractText and SubscribableSession"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #188 — Replace any casts with SDK types
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-24T20:04:58Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced a two-step refactoring plan for replacing `any` casts in `extractText` (with a `TextContent` type predicate) and `SubscribableSession` (with `AgentSessionEvent`).
|
|
13
|
+
Verified that both SDK types are already imported and used in adjacent files within the package.
|
|
14
|
+
Confirmed mock session compatibility via function parameter contravariance — no test changes expected.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
- The `extractText` parameter type stays `unknown[]` to avoid rippling through callers in `message-formatters.ts` that declare `content: unknown[]`.
|
|
19
|
+
A future cleanup could tighten those caller signatures.
|
|
20
|
+
- `SubscribableSession` moves to `src/types.ts` as the shared location, matching existing cross-domain types there (`SubagentType`, `ThinkingLevel`, `ShellExec`).
|
|
21
|
+
- All three `eslint-disable` top-level comments (`context.ts`, `record-observer.ts`, `ui-observer.ts`) should be removable once the `any` casts are gone, since the SDK union's discriminated members cover the property access patterns.
|
|
22
|
+
- Risk: if `AgentSessionEvent` doesn't cover `assistantMessageEvent` in `ui-observer.ts`, the type checker will surface it immediately — the mitigation is to check the union members during implementation.
|
|
23
|
+
|
|
24
|
+
## Stage: Implementation — TDD (2026-05-24T20:17:38Z)
|
|
25
|
+
|
|
26
|
+
### Session summary
|
|
27
|
+
|
|
28
|
+
Completed both TDD steps from the plan.
|
|
29
|
+
Step 1 replaced the `any` filter/map chain in `extractText` with an `isTextContent` type predicate; the `??""` removal was required because `TextContent.text` is non-optional per the SDK type.
|
|
30
|
+
Step 2 moved `SubscribableSession` to `types.ts` typed with `AgentSessionEvent`, removed both duplicate local interfaces, and removed all three `eslint-disable` comments.
|
|
31
|
+
Test count: 902 → 901 (one test removed).
|
|
32
|
+
|
|
33
|
+
### Observations
|
|
34
|
+
|
|
35
|
+
- The `??` operator on `c.text` in `extractText` triggered `@typescript-eslint/no-unnecessary-condition` at commit time because `TextContent.text` is `string` (non-nullable); removing it was necessary, not just cosmetic.
|
|
36
|
+
- After typing the `record-observer` callback as `AgentSessionEvent`, five additional lint errors surfaced: `event.message?.role` (optional chain unnecessary since `MessageEndEvent.message` is required), `if (u)` guard (unnecessary since `AssistantMessage.usage` is required), and three `?? 0` guards on `input`/`output`/`cacheWrite` (all required `number` fields per `Usage` interface in the Pi source at `~/development/pi/pi/packages/ai/src/types.ts`).
|
|
37
|
+
- The test `"ignores message_end without usage"` was removed — it emitted a non-conforming event that the SDK types guarantee cannot occur at runtime.
|
|
38
|
+
- `ui-observer.ts` had one analogous fix: `event.assistantMessageEvent?.type` → `.type` (the field is required on `MessageUpdateEvent`).
|
|
39
|
+
- No test file changes were needed for `ui-observer.ts` — its existing tests all emit conforming events.
|
|
40
|
+
- The plan's contravariance reasoning about mock session compatibility was correct: `pnpm run check` passed without updating `MockSession.subscribe`.
|
|
41
|
+
|
|
42
|
+
## Stage: Final Retrospective (2026-05-24T20:41:42Z)
|
|
43
|
+
|
|
44
|
+
### Session summary
|
|
45
|
+
|
|
46
|
+
Clean two-step refactoring shipped as `pi-subagents-v6.19.1`.
|
|
47
|
+
Both TDD steps completed in a single session with two minor deviations from the plan, both caught by pre-commit hooks.
|
|
48
|
+
Test count: 902 → 901 (one non-conforming test removed).
|
|
49
|
+
|
|
50
|
+
### Observations
|
|
51
|
+
|
|
52
|
+
#### What went well
|
|
53
|
+
|
|
54
|
+
- The plan's contravariance analysis of `MockSession.subscribe` vs `SubscribableSession` was correct — zero test infrastructure changes needed.
|
|
55
|
+
- Pre-commit hooks (ESLint) caught both deviations from the plan at commit time, before they could reach CI.
|
|
56
|
+
- The user providing the Pi source path (`~/development/pi/pi`) unblocked verification of `AssistantMessage.usage` field requirements without guesswork.
|
|
57
|
+
|
|
58
|
+
#### What caused friction (agent side)
|
|
59
|
+
|
|
60
|
+
- `missing-context` — The plan's Risk 3 said "keep `?? ""` for safety" on `TextContent.text`, but `@typescript-eslint/no-unnecessary-condition` rejected it at commit time because `TextContent.text: string` is non-optional.
|
|
61
|
+
Impact: one failed commit attempt, immediate fix (changed `.map((c) => c.text ?? "")` to `.map((c) => c.text)`).
|
|
62
|
+
- `missing-context` — The plan's step 2 didn't anticipate the five additional lint errors surfaced by properly typing the `record-observer` callback: unnecessary optional chain on `event.message?.role`, unnecessary `if (u)` guard, and three unnecessary `?? 0` guards on usage fields.
|
|
63
|
+
Impact: added ~5 minutes of investigation to verify the SDK types were truly non-optional before removing guards; required removing one test case (`"ignores message_end without usage"`).
|
|
64
|
+
Both instances stem from the same root cause: the plan checked that the SDK types *existed* but didn't trace the *field optionality* of types downstream of `AgentSessionEvent` (specifically `AgentMessage.usage` and `Usage.{input,output,cacheWrite}`).
|
|
65
|
+
|
|
66
|
+
#### What caused friction (user side)
|
|
67
|
+
|
|
68
|
+
- The Pi source path (`~/development/pi/pi`) was provided reactively after I'd already spent several tool calls trying to locate `AgentMessage` type definitions in `node_modules`.
|
|
69
|
+
Sharing this path earlier (or noting it in the package skill) would have saved ~4 `grep`/`find` calls.
|
package/package.json
CHANGED
|
@@ -133,23 +133,3 @@ export class AgentTypeRegistry implements AgentConfigLookup {
|
|
|
133
133
|
|
|
134
134
|
/** All known built-in tool names. */
|
|
135
135
|
export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
136
|
-
|
|
137
|
-
/** Tool names required for memory management. */
|
|
138
|
-
const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Get memory tool names (read/write/edit) not already in the provided set.
|
|
142
|
-
*/
|
|
143
|
-
export function getMemoryToolNames(existingToolNames: Set<string>): string[] {
|
|
144
|
-
return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Tool names needed for read-only memory access. */
|
|
148
|
-
const READONLY_MEMORY_TOOL_NAMES = ["read"];
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Get read-only memory tool names not already in the provided set.
|
|
152
|
-
*/
|
|
153
|
-
export function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[] {
|
|
154
|
-
return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
|
|
155
|
-
}
|
|
@@ -7,7 +7,7 @@ import { basename, join } from "node:path";
|
|
|
7
7
|
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { BUILTIN_TOOL_NAMES } from "#src/config/agent-types";
|
|
9
9
|
import { debugLog } from "#src/debug";
|
|
10
|
-
import type { AgentConfig,
|
|
10
|
+
import type { AgentConfig, ThinkingLevel } from "#src/types";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Scan for custom agent .md files from multiple locations.
|
|
@@ -69,7 +69,6 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
|
|
69
69
|
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
|
|
70
70
|
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
|
|
71
71
|
isolated: fm.isolated != null ? fm.isolated === true : undefined,
|
|
72
|
-
memory: parseMemory(fm.memory),
|
|
73
72
|
isolation: fm.isolation === "worktree" ? "worktree" : undefined,
|
|
74
73
|
enabled: fm.enabled !== false, // default true; explicitly false disables
|
|
75
74
|
source,
|
|
@@ -119,15 +118,6 @@ function csvListOptional(val: unknown): string[] | undefined {
|
|
|
119
118
|
return parseCsvField(val);
|
|
120
119
|
}
|
|
121
120
|
|
|
122
|
-
/**
|
|
123
|
-
* Parse a memory scope field.
|
|
124
|
-
* omitted → undefined; "user"/"project"/"local" → MemoryScope.
|
|
125
|
-
*/
|
|
126
|
-
function parseMemory(val: unknown): MemoryScope | undefined {
|
|
127
|
-
if (val === "user" || val === "project" || val === "local") return val;
|
|
128
|
-
return undefined;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
121
|
/**
|
|
132
122
|
* Parse an inherit field (extensions, skills).
|
|
133
123
|
* omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names.
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { createSubagentRuntime } from "#src/runtime";
|
|
|
34
34
|
import { publishSubagentsService, unpublishSubagentsService } from "#src/service/service";
|
|
35
35
|
import { createSubagentsService } from "#src/service/service-adapter";
|
|
36
36
|
import { detectEnv } from "#src/session/env";
|
|
37
|
-
|
|
37
|
+
|
|
38
38
|
import { type ModelRegistry, resolveModel } from "#src/session/model-resolver";
|
|
39
39
|
import { buildAgentPrompt } from "#src/session/prompts";
|
|
40
40
|
import { deriveSubagentSessionDir } from "#src/session/session-dir";
|
|
@@ -146,8 +146,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
146
|
createSession: (opts) => createAgentSession(opts as any),
|
|
147
147
|
assemblerIO: {
|
|
148
148
|
preloadSkills,
|
|
149
|
-
buildMemoryBlock,
|
|
150
|
-
buildReadOnlyMemoryBlock,
|
|
151
149
|
buildAgentPrompt,
|
|
152
150
|
},
|
|
153
151
|
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
|
|
2
1
|
/**
|
|
3
2
|
* record-observer.ts — Subscribes to session events and updates AgentRecord stats.
|
|
4
3
|
*
|
|
@@ -8,11 +7,7 @@
|
|
|
8
7
|
|
|
9
8
|
import type { CompactionInfo } from "#src/lifecycle/agent-manager";
|
|
10
9
|
import type { AgentRecord } from "#src/lifecycle/agent-record";
|
|
11
|
-
|
|
12
|
-
/** Narrow session interface — only the subscribe method needed by the observer. */
|
|
13
|
-
interface SubscribableSession {
|
|
14
|
-
subscribe(fn: (event: any) => void): () => void;
|
|
15
|
-
}
|
|
10
|
+
import type { SubscribableSession } from "#src/types";
|
|
16
11
|
|
|
17
12
|
export interface RecordObserverOptions {
|
|
18
13
|
onCompact?: (record: AgentRecord, info: CompactionInfo) => void;
|
|
@@ -33,20 +28,18 @@ export function subscribeRecordObserver(
|
|
|
33
28
|
record: AgentRecord,
|
|
34
29
|
options?: RecordObserverOptions,
|
|
35
30
|
): () => void {
|
|
36
|
-
return session.subscribe((event
|
|
31
|
+
return session.subscribe((event) => {
|
|
37
32
|
if (event.type === "tool_execution_end") {
|
|
38
33
|
record.incrementToolUses();
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
if (event.type === "message_end" && event.message
|
|
36
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
42
37
|
const u = event.message.usage;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
49
|
-
}
|
|
38
|
+
record.addUsage({
|
|
39
|
+
input: u.input,
|
|
40
|
+
output: u.output,
|
|
41
|
+
cacheWrite: u.cacheWrite,
|
|
42
|
+
});
|
|
50
43
|
}
|
|
51
44
|
|
|
52
45
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
package/src/session/context.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
|
|
2
1
|
/**
|
|
3
2
|
* context.ts — Extract parent conversation context for subagent inheritance.
|
|
4
3
|
*/
|
|
5
4
|
|
|
5
|
+
import type { TextContent } from "@earendil-works/pi-ai";
|
|
6
6
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
|
|
8
|
+
/** Type predicate: narrow an unknown content block to TextContent. */
|
|
9
|
+
function isTextContent(c: unknown): c is TextContent {
|
|
10
|
+
return typeof c === "object" && c !== null && (c as { type: string }).type === "text";
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
/** Extract text from a message content block array. */
|
|
9
14
|
export function extractText(content: unknown[]): string {
|
|
10
15
|
return content
|
|
11
|
-
.filter(
|
|
12
|
-
.map((c
|
|
16
|
+
.filter(isTextContent)
|
|
17
|
+
.map((c) => c.text)
|
|
13
18
|
.join("\n");
|
|
14
19
|
}
|
|
15
20
|
|
package/src/session/prompts.ts
CHANGED
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
import type { EnvInfo } from "#src/session/env";
|
|
6
6
|
import type { AgentPromptConfig } from "#src/types";
|
|
7
7
|
|
|
8
|
-
/** Extra sections to inject into the system prompt (
|
|
8
|
+
/** Extra sections to inject into the system prompt (skills, etc.). */
|
|
9
9
|
export interface PromptExtras {
|
|
10
|
-
/** Persistent memory content to inject (first 200 lines of MEMORY.md + instructions). */
|
|
11
|
-
memoryBlock?: string;
|
|
12
10
|
/** Preloaded skill contents to inject. */
|
|
13
11
|
skillBlocks?: { name: string; content: string }[];
|
|
14
12
|
}
|
|
@@ -45,9 +43,6 @@ Platform: ${env.platform}`;
|
|
|
45
43
|
|
|
46
44
|
// Build optional extras suffix
|
|
47
45
|
const extraSections: string[] = [];
|
|
48
|
-
if (extras?.memoryBlock) {
|
|
49
|
-
extraSections.push(extras.memoryBlock);
|
|
50
|
-
}
|
|
51
46
|
if (extras?.skillBlocks?.length) {
|
|
52
47
|
for (const skill of extras.skillBlocks) {
|
|
53
48
|
extraSections.push(
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-fs.ts — Filesystem safety utilities for reading untrusted paths.
|
|
3
|
+
*
|
|
4
|
+
* Used by skill-loader.ts to reject symlinks and path-traversal names
|
|
5
|
+
* before reading skill files from disk.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
9
|
+
import { debugLog } from "#src/debug";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns true if a name contains characters not allowed in agent/skill names.
|
|
13
|
+
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
|
|
14
|
+
*/
|
|
15
|
+
export function isUnsafeName(name: string): boolean {
|
|
16
|
+
if (!name || name.length > 128) return true;
|
|
17
|
+
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns true if the given path is a symlink (defense against symlink attacks).
|
|
22
|
+
*/
|
|
23
|
+
export function isSymlink(filePath: string): boolean {
|
|
24
|
+
try {
|
|
25
|
+
return lstatSync(filePath).isSymbolicLink();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
debugLog("lstatSync", err);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Safely read a file, rejecting symlinks.
|
|
34
|
+
* Returns undefined if the file doesn't exist, is a symlink, or can't be read.
|
|
35
|
+
*/
|
|
36
|
+
export function safeReadFile(filePath: string): string | undefined {
|
|
37
|
+
if (!existsSync(filePath)) return undefined;
|
|
38
|
+
if (isSymlink(filePath)) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
return readFileSync(filePath, "utf-8");
|
|
41
|
+
} catch (err) {
|
|
42
|
+
debugLog("readFileSync", err);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -10,20 +10,11 @@
|
|
|
10
10
|
* before invoking this function, keeping the assembler synchronous.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
type AgentConfigLookup,
|
|
15
|
-
getMemoryToolNames,
|
|
16
|
-
getReadOnlyMemoryToolNames,
|
|
17
|
-
} from "#src/config/agent-types";
|
|
13
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
18
14
|
import type { EnvInfo } from "#src/session/env";
|
|
19
15
|
import type { PromptExtras } from "#src/session/prompts";
|
|
20
16
|
import type { PreloadedSkill } from "#src/session/skill-loader";
|
|
21
|
-
import type {
|
|
22
|
-
AgentPromptConfig,
|
|
23
|
-
MemoryScope,
|
|
24
|
-
SubagentType,
|
|
25
|
-
ThinkingLevel,
|
|
26
|
-
} from "#src/types";
|
|
17
|
+
import type { AgentPromptConfig, SubagentType, ThinkingLevel } from "#src/types";
|
|
27
18
|
|
|
28
19
|
// ── Public interfaces ────────────────────────────────────────────────────────
|
|
29
20
|
|
|
@@ -52,12 +43,6 @@ export interface ToolFilterConfig {
|
|
|
52
43
|
*/
|
|
53
44
|
export interface AssemblerIO {
|
|
54
45
|
preloadSkills: (skills: string[], cwd: string) => PreloadedSkill[];
|
|
55
|
-
buildMemoryBlock: (name: string, scope: MemoryScope, cwd: string) => string;
|
|
56
|
-
buildReadOnlyMemoryBlock: (
|
|
57
|
-
name: string,
|
|
58
|
-
scope: MemoryScope,
|
|
59
|
-
cwd: string,
|
|
60
|
-
) => string;
|
|
61
46
|
buildAgentPrompt: (
|
|
62
47
|
config: AgentPromptConfig,
|
|
63
48
|
cwd: string,
|
|
@@ -210,38 +195,7 @@ export function assembleSessionConfig(
|
|
|
210
195
|
}
|
|
211
196
|
}
|
|
212
197
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
// Persistent memory: detect write capability and branch accordingly.
|
|
216
|
-
// Account for disallowedTools — a tool in the base set but on the denylist
|
|
217
|
-
// is not truly available.
|
|
218
|
-
if (agentConfig.memory) {
|
|
219
|
-
const existingNames = new Set(toolNames);
|
|
220
|
-
const denied = agentConfig.disallowedTools
|
|
221
|
-
? new Set(agentConfig.disallowedTools)
|
|
222
|
-
: undefined;
|
|
223
|
-
const effectivelyHas = (name: string) =>
|
|
224
|
-
existingNames.has(name) && !denied?.has(name);
|
|
225
|
-
const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
|
|
226
|
-
|
|
227
|
-
if (hasWriteTools) {
|
|
228
|
-
const extraNames = getMemoryToolNames(existingNames);
|
|
229
|
-
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
230
|
-
extras.memoryBlock = io.buildMemoryBlock(
|
|
231
|
-
agentConfig.name,
|
|
232
|
-
agentConfig.memory,
|
|
233
|
-
effectiveCwd,
|
|
234
|
-
);
|
|
235
|
-
} else {
|
|
236
|
-
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
237
|
-
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
|
|
238
|
-
extras.memoryBlock = io.buildReadOnlyMemoryBlock(
|
|
239
|
-
agentConfig.name,
|
|
240
|
-
agentConfig.memory,
|
|
241
|
-
effectiveCwd,
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
198
|
+
const toolNames = registry.getToolNamesForType(type);
|
|
245
199
|
|
|
246
200
|
// Build system prompt from the resolved agent config
|
|
247
201
|
const systemPrompt = io.buildAgentPrompt(
|
|
@@ -24,7 +24,7 @@ import { homedir } from "node:os";
|
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
26
|
import { debugLog } from "#src/debug";
|
|
27
|
-
import { isSymlink, isUnsafeName, safeReadFile } from "#src/session/
|
|
27
|
+
import { isSymlink, isUnsafeName, safeReadFile } from "#src/session/safe-fs";
|
|
28
28
|
|
|
29
29
|
export interface PreloadedSkill {
|
|
30
30
|
name: string;
|
package/src/types.ts
CHANGED
|
@@ -3,17 +3,23 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
export { AgentRecord } from "#src/lifecycle/agent-record";
|
|
9
|
-
export type { ThinkingLevel };
|
|
10
|
+
export type { AgentSessionEvent, ThinkingLevel };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Narrow session interface for event subscription.
|
|
14
|
+
* Used by record-observer and ui-observer — only the subscribe method is needed.
|
|
15
|
+
*/
|
|
16
|
+
export interface SubscribableSession {
|
|
17
|
+
subscribe(fn: (event: AgentSessionEvent) => void): () => void;
|
|
18
|
+
}
|
|
10
19
|
|
|
11
20
|
/** Agent type: any string name (built-in defaults or user-defined). */
|
|
12
21
|
export type SubagentType = string;
|
|
13
22
|
|
|
14
|
-
/** Memory scope for persistent agent memory. */
|
|
15
|
-
export type MemoryScope = "user" | "project" | "local";
|
|
16
|
-
|
|
17
23
|
/** Isolation mode for agent execution. */
|
|
18
24
|
export type IsolationMode = "worktree";
|
|
19
25
|
|
|
@@ -50,8 +56,6 @@ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
|
|
|
50
56
|
runInBackground?: boolean;
|
|
51
57
|
/** Default for spawn: no extension tools. undefined = caller decides. */
|
|
52
58
|
isolated?: boolean;
|
|
53
|
-
/** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
|
|
54
|
-
memory?: MemoryScope;
|
|
55
59
|
/** Isolation mode — "worktree" runs the agent in a temporary git worktree */
|
|
56
60
|
isolation?: IsolationMode;
|
|
57
61
|
/** true = this is an embedded default agent (informational) */
|
|
@@ -131,7 +131,6 @@ export function createAgentConfigEditor(
|
|
|
131
131
|
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
132
132
|
if (cfg.runInBackground) fmFields.push("run_in_background: true");
|
|
133
133
|
if (cfg.isolated) fmFields.push("isolated: true");
|
|
134
|
-
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
135
134
|
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
136
135
|
|
|
137
136
|
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
@@ -115,7 +115,6 @@ disallowed_tools: <comma-separated tool names to block, even if otherwise availa
|
|
|
115
115
|
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
116
116
|
run_in_background: <true to run in background by default. Default: false>
|
|
117
117
|
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
118
|
-
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
119
118
|
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
120
119
|
---
|
|
121
120
|
|
package/src/ui/ui-observer.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
|
|
2
1
|
/**
|
|
3
2
|
* ui-observer.ts — Subscribes to session events and updates AgentActivityTracker state.
|
|
4
3
|
*
|
|
@@ -7,13 +6,9 @@
|
|
|
7
6
|
* turn count, lifetime usage).
|
|
8
7
|
*/
|
|
9
8
|
|
|
9
|
+
import type { SubscribableSession } from "#src/types";
|
|
10
10
|
import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
|
|
11
11
|
|
|
12
|
-
/** Narrow session interface — only the subscribe method needed by the observer. */
|
|
13
|
-
interface SubscribableSession {
|
|
14
|
-
subscribe(fn: (event: any) => void): () => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
12
|
/**
|
|
18
13
|
* Subscribe to session events and stream UI state into an AgentActivityTracker.
|
|
19
14
|
*
|
|
@@ -34,7 +29,7 @@ export function subscribeUIObserver(
|
|
|
34
29
|
tracker: AgentActivityTracker,
|
|
35
30
|
onUpdate?: () => void,
|
|
36
31
|
): () => void {
|
|
37
|
-
return session.subscribe((event
|
|
32
|
+
return session.subscribe((event) => {
|
|
38
33
|
if (event.type === "tool_execution_start") {
|
|
39
34
|
tracker.onToolStart(event.toolName);
|
|
40
35
|
onUpdate?.();
|
|
@@ -51,7 +46,7 @@ export function subscribeUIObserver(
|
|
|
51
46
|
|
|
52
47
|
if (
|
|
53
48
|
event.type === "message_update" &&
|
|
54
|
-
event.assistantMessageEvent
|
|
49
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
55
50
|
) {
|
|
56
51
|
tracker.onMessageUpdate(event.assistantMessageEvent.delta);
|
|
57
52
|
onUpdate?.();
|
package/src/session/memory.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
|
|
3
|
-
*
|
|
4
|
-
* Memory scopes:
|
|
5
|
-
* - "user" → ~/.pi/agent-memory/{agent-name}/
|
|
6
|
-
* - "project" → .pi/agent-memory/{agent-name}/
|
|
7
|
-
* - "local" → .pi/agent-memory-local/{agent-name}/
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { join, } from "node:path";
|
|
13
|
-
import { debugLog } from "#src/debug";
|
|
14
|
-
import type { MemoryScope } from "#src/types";
|
|
15
|
-
|
|
16
|
-
/** Maximum lines to read from MEMORY.md */
|
|
17
|
-
const MAX_MEMORY_LINES = 200;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Returns true if a name contains characters not allowed in agent/skill names.
|
|
21
|
-
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
|
|
22
|
-
*/
|
|
23
|
-
export function isUnsafeName(name: string): boolean {
|
|
24
|
-
if (!name || name.length > 128) return true;
|
|
25
|
-
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Returns true if the given path is a symlink (defense against symlink attacks).
|
|
30
|
-
*/
|
|
31
|
-
export function isSymlink(filePath: string): boolean {
|
|
32
|
-
try {
|
|
33
|
-
return lstatSync(filePath).isSymbolicLink();
|
|
34
|
-
} catch (err) {
|
|
35
|
-
debugLog("lstatSync", err);
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Safely read a file, rejecting symlinks.
|
|
42
|
-
* Returns undefined if the file doesn't exist, is a symlink, or can't be read.
|
|
43
|
-
*/
|
|
44
|
-
export function safeReadFile(filePath: string): string | undefined {
|
|
45
|
-
if (!existsSync(filePath)) return undefined;
|
|
46
|
-
if (isSymlink(filePath)) return undefined;
|
|
47
|
-
try {
|
|
48
|
-
return readFileSync(filePath, "utf-8");
|
|
49
|
-
} catch (err) {
|
|
50
|
-
debugLog("readFileSync", err);
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Resolve the memory directory path for a given agent + scope + cwd.
|
|
57
|
-
* Throws if agentName contains path traversal characters.
|
|
58
|
-
*/
|
|
59
|
-
export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
60
|
-
if (isUnsafeName(agentName)) {
|
|
61
|
-
throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
|
|
62
|
-
}
|
|
63
|
-
switch (scope) {
|
|
64
|
-
case "user":
|
|
65
|
-
return join(homedir(), ".pi", "agent-memory", agentName);
|
|
66
|
-
case "project":
|
|
67
|
-
return join(cwd, ".pi", "agent-memory", agentName);
|
|
68
|
-
case "local":
|
|
69
|
-
return join(cwd, ".pi", "agent-memory-local", agentName);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Ensure the memory directory exists, creating it if needed.
|
|
75
|
-
* Refuses to create directories if any component in the path is a symlink
|
|
76
|
-
* to prevent symlink-based directory traversal attacks.
|
|
77
|
-
*/
|
|
78
|
-
export function ensureMemoryDir(memoryDir: string): void {
|
|
79
|
-
// If the directory already exists, verify it's not a symlink
|
|
80
|
-
if (existsSync(memoryDir)) {
|
|
81
|
-
if (isSymlink(memoryDir)) {
|
|
82
|
-
throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
|
|
83
|
-
}
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
mkdirSync(memoryDir, { recursive: true });
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Read the first N lines of MEMORY.md from the memory directory, if it exists.
|
|
91
|
-
* Returns undefined if no MEMORY.md exists or if the path is a symlink.
|
|
92
|
-
*/
|
|
93
|
-
export function readMemoryIndex(memoryDir: string): string | undefined {
|
|
94
|
-
// Reject symlinked memory directories
|
|
95
|
-
if (isSymlink(memoryDir)) return undefined;
|
|
96
|
-
|
|
97
|
-
const memoryFile = join(memoryDir, "MEMORY.md");
|
|
98
|
-
const content = safeReadFile(memoryFile);
|
|
99
|
-
if (content === undefined) return undefined;
|
|
100
|
-
|
|
101
|
-
const lines = content.split("\n");
|
|
102
|
-
if (lines.length > MAX_MEMORY_LINES) {
|
|
103
|
-
return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)";
|
|
104
|
-
}
|
|
105
|
-
return content;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Build the memory block to inject into the agent's system prompt.
|
|
110
|
-
* Also ensures the memory directory exists (creates it if needed).
|
|
111
|
-
*/
|
|
112
|
-
export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
113
|
-
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
|
114
|
-
// Create the memory directory so the agent can immediately write to it
|
|
115
|
-
ensureMemoryDir(memoryDir);
|
|
116
|
-
|
|
117
|
-
const existingMemory = readMemoryIndex(memoryDir);
|
|
118
|
-
|
|
119
|
-
const header = `# Agent Memory
|
|
120
|
-
|
|
121
|
-
You have a persistent memory directory at: ${memoryDir}/
|
|
122
|
-
Memory scope: ${scope}
|
|
123
|
-
|
|
124
|
-
This memory persists across sessions. Use it to build up knowledge over time.`;
|
|
125
|
-
|
|
126
|
-
const memoryContent = existingMemory
|
|
127
|
-
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
|
128
|
-
: `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`;
|
|
129
|
-
|
|
130
|
-
const instructions = `
|
|
131
|
-
|
|
132
|
-
## Memory Instructions
|
|
133
|
-
- MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated.
|
|
134
|
-
- Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md.
|
|
135
|
-
- Each memory file should use this frontmatter format:
|
|
136
|
-
\`\`\`markdown
|
|
137
|
-
---
|
|
138
|
-
name: <memory name>
|
|
139
|
-
description: <one-line description>
|
|
140
|
-
type: <user|feedback|project|reference>
|
|
141
|
-
---
|
|
142
|
-
<memory content>
|
|
143
|
-
\`\`\`
|
|
144
|
-
- Update or remove memories that become outdated. Check for existing memories before creating duplicates.
|
|
145
|
-
- You have Read, Write, and Edit tools available for managing memory files.`;
|
|
146
|
-
|
|
147
|
-
return header + memoryContent + instructions;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Build a read-only memory block for agents that lack write/edit tools.
|
|
152
|
-
* Does NOT create the memory directory — agents can only consume existing memory.
|
|
153
|
-
*/
|
|
154
|
-
export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
|
|
155
|
-
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
|
|
156
|
-
const existingMemory = readMemoryIndex(memoryDir);
|
|
157
|
-
|
|
158
|
-
const header = `# Agent Memory (read-only)
|
|
159
|
-
|
|
160
|
-
Memory scope: ${scope}
|
|
161
|
-
You have read-only access to memory. You can reference existing memories but cannot create or modify them.`;
|
|
162
|
-
|
|
163
|
-
const memoryContent = existingMemory
|
|
164
|
-
? `\n\n## Current MEMORY.md\n${existingMemory}`
|
|
165
|
-
: `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`;
|
|
166
|
-
|
|
167
|
-
return header + memoryContent;
|
|
168
|
-
}
|