pi-ssh-remote 0.1.10 → 0.1.11

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
@@ -18,6 +18,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
18
18
 
19
19
  - None.
20
20
 
21
+ ## [0.1.11] - 2026-08-16
22
+
23
+ ### Added
24
+
25
+ - Added per-`user@host` server-memory JSON files with stable `id` and `content` entries, plus automatic migration from legacy string memories.
26
+ - Added a local-path exception so Pi's normal `read`, `edit`, and `write` tools can manage the active server's memory file while other operations remain routed over SSH.
27
+ - Added `/remote memory` to display the current server's memory entries, IDs, and JSON file path.
28
+
29
+ ### Changed
30
+
31
+ - Replaced agent-side append/clear memory mutations with prompt-guided entry-level CRUD on the JSON file. Deletion instructions now require an explicit user request, an exact entry ID, and preservation of unrelated entries.
32
+ - Removed `/remote config memory`; the dedicated `/remote memory` command now handles display, while the `remote` tool's `memory` action locates and inspects the JSON file.
33
+
34
+ ### Fixed
35
+
36
+ - None.
37
+
21
38
  ## [0.1.10] - 2026-08-16
22
39
 
23
40
  ### Added
@@ -189,7 +206,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
189
206
 
190
207
  - None.
191
208
 
192
- [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.10...HEAD
209
+ [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.11...HEAD
210
+ [0.1.11]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.10...v0.1.11
193
211
  [0.1.10]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.9...v0.1.10
194
212
  [0.1.9]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.8...v0.1.9
195
213
  [0.1.8]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.7...v0.1.8
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -31,7 +31,7 @@ The extension exposes `remote` for the agent and `/remote` for the user. You can
31
31
 
32
32
  ### Stateful endpoints instead of disposable SSH commands
33
33
 
34
- Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Server memory is shared by `user@host`, so connecting as the same user to the same host through another port uses the same memory. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible. Server memory is automatically injected into the model context whenever a matching endpoint is connected as the active remote workspace.
34
+ Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Server memory is a per-`user@host` JSON file containing stable `{ "id", "content" }` entries, so another SSH port for the same user and host uses the same entries. Pi is told the exact local file path and can use its normal `read`, `edit`, and `write` tools for entry-level CRUD even while other tools are routed remotely. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible. Valid server-memory entries are automatically injected into the model context whenever a matching endpoint is connected as the active remote workspace.
35
35
 
36
36
  ### Resilient and bounded by default
37
37
 
@@ -66,8 +66,9 @@ Requires Node.js 20+. The remote server must provide Bash, SFTP, and GNU `timeou
66
66
  /remote ssh root@gpu-box.example.com -p 2202
67
67
  /remote cd /srv/project
68
68
  /remote config note H100 training server
69
- /remote config memory Use /opt/conda/bin/python and never stop shared jobs.
70
69
  /remote status
70
+
71
+ You: Add a server-memory entry saying to use /opt/conda/bin/python and never stop shared jobs.
71
72
  ```
72
73
 
73
74
  From this point, normal Pi operations target `/srv/project` on the remote server. The footer makes that routing explicit:
@@ -131,7 +132,7 @@ The default foreground timeout prevents an accidental long-running command from
131
132
  /remote
132
133
  ```
133
134
 
134
- Endpoint notes and working directories, plus `user@host`-specific server memories, survive Pi restarts because they are stored in the local remote configuration. When Pi connects to an endpoint, the memory matching its user and host is added to the system context on every agent run while remote tool routing remains active; the SSH port does not affect memory selection. Disconnecting or switching to tunnel-only mode removes it from subsequent model requests.
135
+ Endpoint notes and working directories survive Pi restarts in the local remote configuration. `user@host`-specific memory survives in a separate local JSON file under `~/.pi/agent/ssh-remote-memories/`; each entry has a stable `id` and `content`. When Pi connects to an endpoint, valid entries matching its user and host are added to the model context on every agent run while remote tool routing remains active; the SSH port does not affect memory selection. Disconnecting or switching to tunnel-only mode removes them from subsequent model requests.
135
136
 
136
137
  ### 4. Expose a remote service while editing locally
137
138
 
@@ -164,7 +165,24 @@ Forward the remote service on port 8000 to localhost:8000, but keep my coding
164
165
  tools on the local repository.
165
166
  ```
166
167
 
167
- These requests are handled through the agent-facing `remote` tool; slash commands are optional. For safety, calling its `memory` action without a `memory` argument only reads the current server memory. Non-empty text is appended to the existing memory instead of replacing it; retrying the same final entry is a no-op. Clearing still requires the explicit `--clear` value.
168
+ ```text
169
+ Add a server-memory entry that says deployments must use /opt/deploy/run.sh.
170
+ ```
171
+
172
+ List the current server's entries and IDs before referring to one:
173
+
174
+ ```text
175
+ /remote memory
176
+ ```
177
+
178
+ Then update or delete by the displayed ID:
179
+
180
+ ```text
181
+ Update the entry with ID deploy-command to use /opt/deploy/v2/run.sh.
182
+ Delete the server-memory entry with ID obsolete-proxy-rule.
183
+ ```
184
+
185
+ These requests are handled through the agent-facing `remote` tool; slash commands are optional. The `memory` action locates and inspects the matching JSON file, while Pi's normal `read`, `edit`, and `write` tools perform entry-level add, query, update, and delete operations. The injected instructions make deletion deliberately strict: Pi must first read the file, identify the exact entry ID, and delete only that object—and only after the user explicitly asks to delete, remove, or forget memory. Ambiguous targets require clarification, update requests do not imply deletion, and deleting all entries requires an explicit request to delete all server memory.
168
186
 
169
187
  ## Command reference
170
188
 
@@ -176,8 +194,7 @@ These requests are handled through the agent-facing `remote` tool; slash command
176
194
  | `/remote use USER@HOST:PORT` | Select a saved endpoint |
177
195
  | `/remote config note TEXT` | Persist a note for the selected endpoint |
178
196
  | `/remote config note --clear` | Clear its note |
179
- | `/remote config memory TEXT` | Persist context for the current `user@host`, shared across ports |
180
- | `/remote config memory --clear` | Clear the current `user@host` memory |
197
+ | `/remote memory` | List the current server's memory entries, IDs, and JSON file path |
181
198
  | `/remote config cwd PATH` | Persist its default remote working directory |
182
199
  | `/remote cd PATH` | Change the connected remote cwd and persist it |
183
200
  | `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
@@ -205,7 +222,7 @@ Endpoint configuration is stored locally in:
205
222
  ~/.pi/agent/ssh-remote-config.json
206
223
  ```
207
224
 
208
- Saved global values include endpoints, active endpoint, notes, `user@host`-specific server memories, remote working directories, forwards, identity file paths, preview settings, and model-output budgets. Each Pi session also stores non-secret SSH workspace metadata so `/resume` can restore the server associated with that session. Server memory is user-configured trusted context and is inserted into each model request only while a matching endpoint is the active remote workspace; it is not read from the remote server. Private key contents, passwords, and key passphrases are **never written to this file**. Private keys are read locally only when connecting; prompted passwords and passphrases remain only in process memory.
225
+ Saved global values include endpoints, active endpoint, notes, remote working directories, forwards, identity file paths, preview settings, and model-output budgets. Server-memory entries are stored separately under `~/.pi/agent/ssh-remote-memories/` as `{ "server", "entries": [{ "id", "content" }] }`; legacy string memories are migrated automatically. Each Pi session also stores non-secret SSH workspace metadata so `/resume` can restore the server associated with that session. Server memory is user-configured trusted context and is inserted into each model request only while a matching endpoint is the active remote workspace; it is not read from the remote server. The current server's exact memory path is the only local path that the routed `read`, `edit`, and `write` tools special-case. Private key contents, passwords, and key passphrases are **never written to these files**. Private keys are read locally only when connecting; prompted passwords and passphrases remain only in process memory.
209
226
 
210
227
  New or changed host keys require interactive confirmation and are stored separately from OpenSSH. By default, remote text reads return at most 400 lines or 16 KB, remote command results return the last 200 lines or 8 KB, and all remote tools in one agent turn share a 32 KB output budget. Text reads support `offset`/`limit` continuation without downloading the complete remote file. Oversized command output is streamed to a permission-restricted temporary local file rather than accumulated in memory. Configured limits may be raised only to the extension's hard safety ceilings. Preview-line settings affect only the collapsed UI and never increase model output.
211
228
 
@@ -215,13 +232,13 @@ The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`.
215
232
 
216
233
  ## Releases
217
234
 
218
- Latest release: [v0.1.10](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.10)
235
+ Latest release: [v0.1.11](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.11)
219
236
 
220
237
  | Version | Date | Highlights |
221
238
  |---|---|---|
239
+ | [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON memory entries, prompt-guided CRUD, and `/remote memory` listing |
222
240
  | [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | Safe inspect-by-default and append-only agent updates for server memory |
223
241
  | [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | Stable deduplicated server-memory context and shorter footer labels |
224
- | [0.1.8](CHANGELOG.md#018---2026-08-10) | 2026-08-10 | Explicit private-key login, cross-port server memory, and the shorter `remote` tool name |
225
242
 
226
243
  See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
227
244
 
package/README.zh-CN.md CHANGED
@@ -54,7 +54,7 @@ H100 训练机 (root@gpu-box.example.com:2202):/srv/project
54
54
  - 服务器特定记忆;
55
55
  - 端口转发配置。
56
56
 
57
- 例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host` 保存,例如指定 Python 环境、共享任务保护规则或部署约定;同一用户和主机即使通过不同 SSH 端口连接,也会使用同一份记忆。连接匹配的 endpoint 并启用远端工具路由后,这段记忆会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
57
+ 例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host` 保存为 JSON 文件,每条记忆都是稳定的 `{ "id", "content" }` 对象;同一用户和主机即使通过不同 SSH 端口连接,也会使用同一组条目。插件会把精确的本地文件路径告诉 Pi,因此即使其他工具已路由到远端,Pi 仍能用普通 `read`、`edit`、`write` 工具对记忆条目增删查改。连接匹配的 endpoint 并启用远端工具路由后,有效条目会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
58
58
 
59
59
  ### 断线后可以自动恢复
60
60
 
@@ -107,7 +107,12 @@ pi install npm:pi-ssh-remote
107
107
  ```text
108
108
  /remote cd /srv/project
109
109
  /remote config note H100 训练机
110
- /remote config memory 使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
110
+ ```
111
+
112
+ 然后直接告诉 Pi:
113
+
114
+ ```text
115
+ 添加一条服务器记忆:使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
111
116
  ```
112
117
 
113
118
  查看当前状态:
@@ -183,7 +188,7 @@ pi install npm:pi-ssh-remote
183
188
  /remote
184
189
  ```
185
190
 
186
- 备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖;服务器记忆则按 `user@host` 共享,不受端口影响。
191
+ 备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖;服务器记忆则按 `user@host` 保存为独立 JSON 文件并跨端口共享。
187
192
 
188
193
  ### 4. 远端启动模型,本地开发界面
189
194
 
@@ -218,7 +223,24 @@ pi install npm:pi-ssh-remote
218
223
  把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
219
224
  ```
220
225
 
221
- Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删,调用 `memory` action 但不传 `memory` 参数时只会读取当前服务器记忆;传入非空内容时会追加到现有记忆,而不是覆盖,重复提交相同的末尾内容不会再次追加;清除时仍必须显式传入 `--clear`。
226
+ ```text
227
+ 添加一条服务器记忆:部署必须使用 /opt/deploy/run.sh。
228
+ ```
229
+
230
+ 更新或删除前,可以先展示当前服务器的全部记忆和 ID:
231
+
232
+ ```text
233
+ /remote memory
234
+ ```
235
+
236
+ 然后使用展示出的 ID:
237
+
238
+ ```text
239
+ 把 ID 为 deploy-command 的记忆更新为使用 /opt/deploy/v2/run.sh。
240
+ 删除 ID 为 obsolete-proxy-rule 的服务器记忆。
241
+ ```
242
+
243
+ Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action 只负责定位和查看匹配的 JSON 文件,实际增删查改使用 Pi 自带的 `read`、`edit`、`write`。删除规则会明确注入给 Agent:只有用户明确要求“删除/移除/忘记”服务器记忆时,Pi 才能先读取文件、确认准确条目 ID,再只删除该对象;目标不明确时必须询问,修改请求不等于删除授权,删除全部条目也必须由用户明确提出。
222
244
 
223
245
  ## 命令说明
224
246
 
@@ -230,8 +252,7 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
230
252
  | `/remote use USER@HOST:PORT` | 切换到指定服务器 |
231
253
  | `/remote config note TEXT` | 给当前服务器添加或修改备注 |
232
254
  | `/remote config note --clear` | 清除当前服务器备注 |
233
- | `/remote config memory TEXT` | 保存当前 `user@host` 的记忆,并在不同端口间共享 |
234
- | `/remote config memory --clear` | 清除当前 `user@host` 的记忆 |
255
+ | `/remote memory` | 展示当前服务器的全部记忆条目、ID JSON 文件路径 |
235
256
  | `/remote config cwd PATH` | 设置默认远程工作目录 |
236
257
  | `/remote cd PATH` | 切换当前远程目录并保存 |
237
258
  | `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
@@ -264,16 +285,21 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
264
285
  - 已保存的服务器;
265
286
  - 当前选中的服务器;
266
287
  - 服务器备注;
267
- - 服务器特定记忆;
268
288
  - 默认远程目录;
269
289
  - 端口转发配置;
270
290
  - 私钥文件路径;
271
291
  - 命令预览设置;
272
292
  - 模型输出预算。
273
293
 
274
- 此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。
294
+ 服务器记忆单独保存在:
295
+
296
+ ```text
297
+ ~/.pi/agent/ssh-remote-memories/
298
+ ```
299
+
300
+ 文件结构为 `{ "server", "entries": [{ "id", "content" }] }`,旧版字符串记忆会自动迁移为 JSON 条目。此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。远端路由启用时,只有当前服务器对应的精确记忆文件路径会被 `read`、`edit`、`write` 特殊处理为本地文件。
275
301
 
276
- 私钥内容、密码和密钥 passphrase 都不会写入配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
302
+ 私钥内容、密码和密钥 passphrase 都不会写入这些配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
277
303
 
278
304
  ## 安全与输出限制
279
305
 
@@ -294,13 +320,13 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
294
320
 
295
321
  ## 版本发布
296
322
 
297
- 最新版本:[v0.1.10](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.10)
323
+ 最新版本:[v0.1.11](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.11)
298
324
 
299
325
  | 版本 | 日期 | 主要内容 |
300
326
  |---|---|---|
327
+ | [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON 记忆条目、Prompt 引导增删查改与 `/remote memory` 展示命令 |
301
328
  | [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | 默认安全读取且仅追加的服务器记忆 agent 更新逻辑 |
302
329
  | [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | 稳定且去重的服务器记忆上下文与更短的状态栏标签 |
303
- | [0.1.8](CHANGELOG.md#018---2026-08-10) | 2026-08-10 | 显式私钥登录、跨端口服务器记忆与更短的 `remote` 工具名 |
304
330
 
305
331
  完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
306
332
 
package/index.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  */
8
8
 
9
9
  import ssh2, { type Client as SshClient, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
10
- import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
11
- import { dirname, isAbsolute, join, posix, relative, sep } from "node:path";
10
+ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
11
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
12
12
  import { createServer, type Server, type Socket } from "node:net";
13
13
  import { tmpdir } from "node:os";
14
14
  import { Type } from "typebox";
@@ -64,9 +64,20 @@ interface RemoteEndpointConfig {
64
64
  memory?: string;
65
65
  }
66
66
 
67
+ interface ServerMemoryEntry {
68
+ id: string;
69
+ content: string;
70
+ }
71
+
72
+ interface ServerMemoryFile {
73
+ server: string;
74
+ entries: ServerMemoryEntry[];
75
+ }
76
+
67
77
  interface RemoteConfig {
68
78
  activeEndpoint?: string;
69
79
  endpoints?: Record<string, RemoteEndpointConfig>;
80
+ /** Legacy values migrated into per-server JSON files at extension startup. */
70
81
  serverMemories?: Record<string, string>;
71
82
  displayLines?: number;
72
83
  readMaxLines?: number;
@@ -98,6 +109,7 @@ interface SessionRemoteState {
98
109
  const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
99
110
  const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
100
111
  const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
112
+ const SERVER_MEMORY_DIR = join(AGENT_DIR, "ssh-remote-memories");
101
113
  const FALLBACK_REMOTE_CWD = "~";
102
114
  const DEFAULT_DISPLAY_LINES = 5;
103
115
  const MAX_DISPLAY_LINES = 50;
@@ -412,21 +424,103 @@ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()):
412
424
  return note ? `${note} (${endpoint.label})` : endpoint.label;
413
425
  }
414
426
 
415
- function endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
416
- return config.serverMemories?.[serverMemoryId(endpoint)]?.trim() || undefined;
427
+ function serverMemoryFilePath(endpoint: ParsedSsh | string): string {
428
+ const server = typeof endpoint === "string" ? endpoint : serverMemoryId(endpoint);
429
+ return join(SERVER_MEMORY_DIR, `${Buffer.from(server, "utf8").toString("base64url")}.json`);
430
+ }
431
+
432
+ function legacyMemoryEntries(memory: string): ServerMemoryEntry[] {
433
+ return memory
434
+ .trim()
435
+ .split(/\n\s*\n/)
436
+ .map((content) => content.trim())
437
+ .filter(Boolean)
438
+ .map((content, index) => ({ id: `legacy-${String(index + 1).padStart(3, "0")}`, content }));
439
+ }
440
+
441
+ function writeServerMemoryFile(path: string, memory: ServerMemoryFile): void {
442
+ mkdirSync(dirname(path), { recursive: true });
443
+ writeFileSync(path, JSON.stringify(memory, null, 2) + "\n", { mode: 0o600 });
444
+ }
445
+
446
+ function migrateLegacyServerMemories(): void {
447
+ const config = loadRemoteConfig();
448
+ const legacy = config.serverMemories ?? {};
449
+ if (!Object.keys(legacy).length) {
450
+ mkdirSync(SERVER_MEMORY_DIR, { recursive: true });
451
+ return;
452
+ }
453
+
454
+ const remaining = { ...legacy };
455
+ let changed = false;
456
+ for (const [server, content] of Object.entries(legacy)) {
457
+ const path = serverMemoryFilePath(server);
458
+ try {
459
+ if (!existsSync(path)) {
460
+ writeServerMemoryFile(path, { server, entries: legacyMemoryEntries(content) });
461
+ }
462
+ delete remaining[server];
463
+ changed = true;
464
+ } catch {}
465
+ }
466
+ if (changed) saveRemoteConfig({ ...config, serverMemories: remaining });
467
+ }
468
+
469
+ function ensureServerMemoryFile(endpoint: ParsedSsh): string {
470
+ const path = serverMemoryFilePath(endpoint);
471
+ if (!existsSync(path)) writeServerMemoryFile(path, { server: serverMemoryId(endpoint), entries: [] });
472
+ return path;
473
+ }
474
+
475
+ function loadServerMemory(endpoint: ParsedSsh): ServerMemoryFile | undefined {
476
+ const path = serverMemoryFilePath(endpoint);
477
+ if (!existsSync(path)) return undefined;
478
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ServerMemoryFile>;
479
+ if (parsed.server !== serverMemoryId(endpoint) || !Array.isArray(parsed.entries)) {
480
+ throw new Error("expected an object with the matching server and an entries array");
481
+ }
482
+ const ids = new Set<string>();
483
+ const entries = parsed.entries.map((entry, index) => {
484
+ if (!entry || typeof entry.id !== "string" || !entry.id.trim() || typeof entry.content !== "string" || !entry.content.trim()) {
485
+ throw new Error(`entry ${index + 1} must contain non-empty string id and content fields`);
486
+ }
487
+ const id = entry.id.trim();
488
+ if (ids.has(id)) throw new Error(`duplicate entry id: ${id}`);
489
+ ids.add(id);
490
+ return { id, content: entry.content.trim() };
491
+ });
492
+ return { server: parsed.server, entries };
493
+ }
494
+
495
+ function formatServerMemoryEntries(memory: ServerMemoryFile | undefined): string {
496
+ if (!memory?.entries.length) return "No entries.";
497
+ return memory.entries.map((entry) => `[${entry.id}]\n${entry.content}`).join("\n\n");
498
+ }
499
+
500
+ function memoryManagementPrompt(remote: RemoteState): string {
501
+ let path = serverMemoryFilePath(remote);
502
+ try { path = ensureServerMemoryFile(remote); }
503
+ catch {}
504
+ return `Persistent memory for this SSH server is a local JSON file at ${path}. This exact path is always handled by Pi's local read, write, and edit tools even while other tools are routed over SSH. The file schema is {"server":"${serverMemoryId(remote)}","entries":[{"id":"stable-unique-id","content":"memory text"}]}. If the file does not exist, create it with that server value and an empty entries array. Read the file before changing it and preserve valid JSON plus all unrelated entries. Add by appending one object with a unique stable id; query by reading the file; update by editing only the matching id. DELETE SAFETY: delete an entry only when the user explicitly asks to delete, remove, or forget server memory. Before deleting, read the file and identify the exact id; if the target is ambiguous, ask the user. Use edit to remove only that exact object and preserve every other entry. Never treat a correction or replacement request as permission to delete, and never delete all entries unless the user explicitly requests deletion of all server memory.`;
417
505
  }
418
506
 
419
507
  function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
420
- return systemPrompt.replace(
508
+ const workspacePrompt = systemPrompt.replace(
421
509
  `Current working directory: ${localCwd}`,
422
- `Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server. Use remote with action disconnect to return to the local environment when requested.`,
510
+ `Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server, except for the explicitly identified local server-memory JSON file. Use remote with action disconnect to return to the local environment when requested.`,
423
511
  );
512
+ return `${workspacePrompt}\n\n${memoryManagementPrompt(remote)}`;
424
513
  }
425
514
 
426
515
  function serverMemoryContext(remote: RemoteState): string | undefined {
427
- const memory = endpointMemory(remote);
428
- if (!memory) return undefined;
429
- return `<ssh_remote_server_memory endpoint="${remote.label}">\nThe following is user-configured, persistent memory specific to this SSH server. Apply it while working on this server:\n${memory}\n</ssh_remote_server_memory>`;
516
+ const path = serverMemoryFilePath(remote);
517
+ let memory: ServerMemoryFile | undefined;
518
+ try { memory = loadServerMemory(remote); }
519
+ catch (error) {
520
+ return `<ssh_remote_server_memory endpoint="${remote.label}" path="${path}">\nThe server-memory JSON file is invalid and must not be applied until repaired: ${(error as Error).message}\n</ssh_remote_server_memory>`;
521
+ }
522
+ if (!memory?.entries.length) return undefined;
523
+ return `<ssh_remote_server_memory endpoint="${remote.label}" path="${path}">\nThe following user-configured JSON entries are persistent memory specific to this SSH server. Apply their content while working on this server:\n${JSON.stringify(memory.entries, null, 2)}\n</ssh_remote_server_memory>`;
430
524
  }
431
525
 
432
526
  function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, makeActive = false): void {
@@ -442,23 +536,6 @@ function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, make
442
536
  });
443
537
  }
444
538
 
445
- function saveServerMemory(command: string, memory: string | undefined): void {
446
- const config = loadRemoteConfig();
447
- const parsed = parseSshCommand(command);
448
- const endpointKey = cacheId(parsed);
449
- const memoryKey = serverMemoryId(parsed);
450
- const serverMemories = { ...(config.serverMemories ?? {}) };
451
- if (memory) serverMemories[memoryKey] = memory;
452
- else delete serverMemories[memoryKey];
453
- saveRemoteConfig({
454
- ...config,
455
- serverMemories,
456
- endpoints: {
457
- ...(config.endpoints ?? {}),
458
- [endpointKey]: { ...endpointConfig(config, command), sshCommand: command },
459
- },
460
- });
461
- }
462
539
 
463
540
  function loadKnownHosts(): Record<string, string> {
464
541
  try { return JSON.parse(readFileSync(KNOWN_HOSTS_FILE, "utf8")); }
@@ -782,6 +859,8 @@ async function askPassword(ctx: any): Promise<string | null> {
782
859
  }
783
860
 
784
861
  export default function sshRemoteExtension(pi: ExtensionAPI) {
862
+ migrateLegacyServerMemories();
863
+
785
864
  const localCwd = process.cwd();
786
865
  let remote: RemoteState | null = null;
787
866
  let routeRemoteTools = false;
@@ -1270,16 +1349,20 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1270
1349
  const localWrite = createWriteTool(localCwd);
1271
1350
  const localEdit = createEditTool(localCwd);
1272
1351
  const localBash = createBashTool(localCwd);
1352
+ const targetsLocalServerMemory = (path: unknown): boolean => {
1353
+ if (!remote || typeof path !== "string") return false;
1354
+ return resolve(path.replace(/^@/, "")) === serverMemoryFilePath(remote);
1355
+ };
1273
1356
  pi.registerTool({
1274
1357
  ...localRead,
1275
1358
  description: `Read file contents. Remote text reads fetch only the requested range and return at most ${DEFAULT_READ_MAX_LINES} lines or ${formatSize(DEFAULT_READ_MAX_BYTES)} by default; use offset/limit to continue.`,
1276
1359
  promptGuidelines: ["Use read with offset/limit for remote code and logs; inspect large files in focused chunks instead of reading them wholesale."],
1277
- execute: (id, params, signal, update) => remote && routeRemoteTools
1360
+ execute: (id, params, signal, update) => remote && routeRemoteTools && !targetsLocalServerMemory(params.path)
1278
1361
  ? executeRemoteRead(id, params, signal, update)
1279
1362
  : localRead.execute(id, params, signal, update),
1280
1363
  });
1281
- pi.registerTool({ ...localWrite, execute: (id, params, signal, update) => remote && routeRemoteTools ? createWriteTool(localCwd, { operations: remoteWriteOps() }).execute(id, params, signal, update) : localWrite.execute(id, params, signal, update) });
1282
- pi.registerTool({ ...localEdit, execute: (id, params, signal, update) => remote && routeRemoteTools ? createEditTool(localCwd, { operations: remoteEditOps() }).execute(id, params, signal, update) : localEdit.execute(id, params, signal, update) });
1364
+ pi.registerTool({ ...localWrite, execute: (id, params, signal, update) => remote && routeRemoteTools && !targetsLocalServerMemory(params.path) ? createWriteTool(localCwd, { operations: remoteWriteOps() }).execute(id, params, signal, update) : localWrite.execute(id, params, signal, update) });
1365
+ pi.registerTool({ ...localEdit, execute: (id, params, signal, update) => remote && routeRemoteTools && !targetsLocalServerMemory(params.path) ? createEditTool(localCwd, { operations: remoteEditOps() }).execute(id, params, signal, update) : localEdit.execute(id, params, signal, update) });
1283
1366
  pi.registerTool({
1284
1367
  ...localBash,
1285
1368
  description: `Execute a shell command. Remote model-facing output returns at most the last ${DEFAULT_EXEC_MAX_LINES} lines or ${formatSize(DEFAULT_EXEC_MAX_BYTES)} by default; complete oversized output is saved locally.`,
@@ -1294,12 +1377,13 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1294
1377
  pi.registerTool({
1295
1378
  name: "remote",
1296
1379
  label: "Remote",
1297
- description: "Connect, reconnect, annotate endpoints, manage server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Connections support SSH agent, password, or an explicit local private key with -i. Exec output is streamed to bounded buffers; model output defaults to the last 200 lines or 8KB, while complete oversized output is saved locally. Passwords and key passphrases are never accepted as arguments and are cached only in process memory.",
1298
- promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
1380
+ description: "Connect, reconnect, annotate endpoints, locate server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Server memory is managed as JSON entries with Pi's read, write, and edit tools. Connections support SSH agent, password, or an explicit local private key with -i. Exec output is streamed to bounded buffers; model output defaults to the last 200 lines or 8KB, while complete oversized output is saved locally. Passwords and key passphrases are never accepted as arguments and are cached only in process memory.",
1381
+ promptSnippet: "Control the configured remote SSH connection, endpoint note and memory location, working directory, and local port forwarding",
1299
1382
  promptGuidelines: [
1300
1383
  "Use remote when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
1301
1384
  "Use remote with action chdir when the user asks to change the remote working directory; do not emulate a persistent directory change with action exec and a one-command cwd.",
1302
- "Use remote with action memory and no memory argument to inspect server memory. Non-empty memory text appends to the existing memory; clear it only by passing --clear explicitly.",
1385
+ "Use remote with action memory to locate and inspect the current server-memory JSON file, then use read/edit/write on that exact local path for entry-level changes.",
1386
+ "Delete a server-memory JSON entry only after an explicit user request to delete, remove, or forget it. Read the file first, identify the exact entry id, and remove only that object with edit; ask the user if the target is ambiguous and never infer deletion from an update request.",
1303
1387
  `Always set timeout for remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
1304
1388
  "Keep remote exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
1305
1389
  "Use remote with action disconnect after remote work when the user asks to return to the local environment.",
@@ -1308,7 +1392,6 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1308
1392
  action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
1309
1393
  command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22 or ssh -i ~/.ssh/id_ed25519 root@host; optionally selects the endpoint for note or memory" })),
1310
1394
  note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
1311
- memory: Type.Optional(Type.String({ description: "Persistent context shared by the endpoint's user@host across SSH ports; omit to inspect, provide non-empty text to append without overwriting existing memory, or use --clear to clear explicitly" })),
1312
1395
  cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
1313
1396
  forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
1314
1397
  remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
@@ -1357,47 +1440,29 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1357
1440
  if (params.action === "note" || params.action === "memory") {
1358
1441
  const command = params.command || lastCommand || activeSshCommand();
1359
1442
  if (!command) throw new Error("No SSH endpoint configured; connect or select an endpoint first");
1360
- const label = parseSshCommand(command).label;
1443
+ const parsed = parseSshCommand(command);
1444
+ const label = parsed.label;
1361
1445
  if (params.action === "note") {
1362
1446
  const note = params.note?.trim() || undefined;
1363
1447
  saveEndpointConfig(command, { note });
1364
- if (remote && cacheId(remote) === cacheId(parseSshCommand(command)) && currentCtx) status(currentCtx);
1448
+ if (remote && cacheId(remote) === cacheId(parsed) && currentCtx) status(currentCtx);
1365
1449
  return {
1366
1450
  content: [{ type: "text", text: note ? `SSH remote note updated (${label}): ${note}` : `SSH remote note cleared (${label})` }],
1367
1451
  details: { endpoint: label, note },
1368
1452
  };
1369
1453
  }
1370
- const parsed = parseSshCommand(command);
1371
- const server = serverMemoryId(parsed);
1372
- if (params.memory === undefined) {
1373
- const memory = endpointMemory(parsed);
1374
- return {
1375
- content: [{ type: "text", text: memory ? `SSH remote server memory (${server}):\n${memory}` : `No SSH remote server memory is configured (${server}).` }],
1376
- details: { endpoint: label, server, memory },
1377
- };
1454
+ const path = ensureServerMemoryFile(parsed);
1455
+ let memory: ServerMemoryFile | undefined;
1456
+ try { memory = loadServerMemory(parsed); }
1457
+ catch (error) {
1458
+ throw new Error(`Invalid server-memory JSON at ${path}: ${(error as Error).message}`);
1378
1459
  }
1379
- const addition = params.memory.trim();
1380
- if (!addition) throw new Error("memory must contain text; omit it to inspect the current memory or use --clear to clear it explicitly");
1381
- if (addition.toLowerCase() === "--clear") {
1382
- saveServerMemory(command, undefined);
1383
- return {
1384
- content: [{ type: "text", text: `SSH remote server memory cleared (${server}).` }],
1385
- details: { endpoint: label, server, memory: undefined },
1386
- };
1387
- }
1388
- const currentMemory = endpointMemory(parsed);
1389
- const duplicate = currentMemory === addition || currentMemory?.endsWith(`\n\n${addition}`);
1390
- if (duplicate) {
1391
- return {
1392
- content: [{ type: "text", text: `SSH remote server memory already ends with this content (${server}); nothing was changed.` }],
1393
- details: { endpoint: label, server, memory: currentMemory, addedMemory: addition, changed: false },
1394
- };
1395
- }
1396
- const memory = currentMemory ? `${currentMemory}\n\n${addition}` : addition;
1397
- saveServerMemory(command, memory);
1460
+ const text = memory?.entries.length
1461
+ ? `SSH remote server memory (${serverMemoryId(parsed)}) is stored at ${path}:\n${JSON.stringify(memory.entries, null, 2)}`
1462
+ : `No server-memory entries are configured for ${serverMemoryId(parsed)}. Use write to create ${path} with schema {"server":"${serverMemoryId(parsed)}","entries":[]}, then use edit for entry-level changes.`;
1398
1463
  return {
1399
- content: [{ type: "text", text: `SSH remote server memory appended (${server}). It applies to every port for this user and host.` }],
1400
- details: { endpoint: label, server, memory, addedMemory: addition, changed: true },
1464
+ content: [{ type: "text", text }],
1465
+ details: { endpoint: label, server: serverMemoryId(parsed), path, entries: memory?.entries ?? [] },
1401
1466
  };
1402
1467
  }
1403
1468
  if (params.action === "exec") {
@@ -1444,7 +1509,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1444
1509
  });
1445
1510
 
1446
1511
  pi.registerCommand("remote", {
1447
- description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] [-i KEY] | config | use USER@HOST:PORT | config note TEXT|--clear | config memory TEXT|--clear | config cwd PATH | config display-lines N | config read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
1512
+ description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] [-i KEY] | memory | config | use USER@HOST:PORT | config note TEXT|--clear | config cwd PATH | config display-lines N | config read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
1448
1513
  handler: async (args, ctx) => {
1449
1514
  const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
1450
1515
  const action = input.toLowerCase();
@@ -1453,8 +1518,18 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1453
1518
  const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
1454
1519
  const active = key === config.activeEndpoint ? "*" : " ";
1455
1520
  const command = endpoint.sshCommand || commandFromEndpointKey(key);
1456
- const memory = command ? endpointMemory(parseSshCommand(command), config) : undefined;
1457
- return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${memory || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
1521
+ let memory = "none";
1522
+ if (command) {
1523
+ const parsed = parseSshCommand(command);
1524
+ const path = serverMemoryFilePath(parsed);
1525
+ try {
1526
+ const entries = loadServerMemory(parsed)?.entries ?? [];
1527
+ memory = `${entries.length} JSON entr${entries.length === 1 ? "y" : "ies"} (${path})`;
1528
+ } catch (error) {
1529
+ memory = `invalid JSON (${path}: ${(error as Error).message})`;
1530
+ }
1531
+ }
1532
+ return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${memory}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
1458
1533
  });
1459
1534
  const limits = configuredOutputLimits(config);
1460
1535
  ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\nDisplay lines: ${configuredDisplayLines(config)}\nRead output: ${limits.readMaxLines} lines / ${formatSize(limits.readMaxBytes)}\nExec output: ${limits.execMaxLines} lines / ${formatSize(limits.execMaxBytes)}\nPer-turn output: ${formatSize(limits.turnMaxBytes)}\n${rows.join("\n") || "No saved endpoints"}`, "info");
@@ -1508,15 +1583,19 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1508
1583
  ctx.ui.notify(note ? `SSH remote note updated (${parseSshCommand(command).label}): ${note}` : `SSH remote note cleared (${parseSshCommand(command).label})`, "info");
1509
1584
  return;
1510
1585
  }
1511
- if (/^config\s+memory(?:\s+|$)/i.test(input)) {
1512
- const value = input.replace(/^config\s+memory\s*/i, "").trim();
1513
- if (!value) { ctx.ui.notify("Use /remote config memory TEXT or /remote config memory --clear", "error"); return; }
1514
- const command = lastCommand || activeSshCommand();
1586
+ if (action === "memory") {
1587
+ const command = remote?.command || lastCommand || activeSshCommand();
1515
1588
  if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
1516
- const memory = value.toLowerCase() === "--clear" ? undefined : value;
1517
- const server = serverMemoryId(parseSshCommand(command));
1518
- saveServerMemory(command, memory);
1519
- ctx.ui.notify(memory ? `SSH remote server memory updated (${server}); it applies to every port` : `SSH remote server memory cleared (${server})`, "info");
1589
+ const parsed = parseSshCommand(command);
1590
+ let path: string;
1591
+ try { path = ensureServerMemoryFile(parsed); }
1592
+ catch (error) { ctx.ui.notify(`Could not initialize server-memory JSON: ${(error as Error).message}`, "error"); return; }
1593
+ try {
1594
+ const memory = loadServerMemory(parsed);
1595
+ ctx.ui.notify(`SSH remote server memory (${serverMemoryId(parsed)})\nFile: ${path}\n\n${formatServerMemoryEntries(memory)}`, "info");
1596
+ } catch (error) {
1597
+ ctx.ui.notify(`Invalid server-memory JSON at ${path}: ${(error as Error).message}`, "error");
1598
+ }
1520
1599
  return;
1521
1600
  }
1522
1601
  if (/^config\s+display-lines\s+/i.test(input)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ssh-remote",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",