🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

NewPi 桌面外壳 cyril-belin/newpi

DeepSeek Harness
cyril-belin42 天前有提交已提供中文文档扫描:高危

在原生 macOS 窗口中管理项目记忆与引擎能力

29.5/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/9/27 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add cyril-belin/newpi
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:仅索引
是什么
dsh 原生插件 · chat
装得上吗
本站尚未做安装检查
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 42 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
🔴 安全扫描发现高危能力自动 · 静态分析源码
v2.7.0
🔴 扫描:高危尚无人工评估

静态扫描发现安装期即执行代码,或命令参数由运行时变量拼接(参数来源无法静态判定,从严处理)。

安装时即执行脚本可执行外部命令读写/删除本地文件发起外部网络请求
扫描于 2026/11/4(0 天前) · 范围 入口闭包 + 全包扫描 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 原生macOS窗口
  • 按项目持久记忆
  • 不改动harness本身

原生 macOS 窗口、按项目持久记忆,还有额外能力,全都给 DeepSeek Harness,不用改 harness 本身。

README

由 DeepSeek 最新模型翻译生成
NewPi Tests Licence : MIT Plateforme : macOS 12+ 一个原生 macOS 应用程序,为 DeepSeek Harness 引擎提供一个窗口、持久的项目记忆和各项能力——而无需修改引擎。 “项目”部分:已打开的项目、其五项能力、最近的项目以及项目切换 项目状态 NewPi 是一个年轻的项目,发布出来是为了供人阅读、试用和接手。这张表说明了今天哪些功能可用、哪些仍然缺失,不做任何粉饰。 | 可用功能 | 引擎的窗口与生命周期;在固定并经过校验的 PocketBase 上实现的项目持久记忆;Memory、Backup、Storage、Projets、Git、Model Router、Context & Cache 和 Terminal 等部分;完整的 NewPi 外观(图标、名称、侧边栏) | | 缺失功能 | 运行时未内嵌:Node 和 dsh 必须安装在机器上;未分发任何已签名或已公证的软件包;是否内嵌运行时的决定仍未确定 | | 平台 | macOS 12 或更高版本。
展开完整文档(README 剩余 1 节)
NewPi

Tests
Licence : MIT
Plateforme : macOS 12+

一个原生 macOS 应用程序,为 DeepSeek Harness 引擎提供一个窗口、持久的项目记忆和各项能力——而无需修改引擎。

“项目”部分:已打开的项目、其五项能力、最近的项目以及项目切换

项目状态

NewPi 是一个年轻的项目,发布出来是为了供人阅读、试用和接手。这张表说明了今天哪些功能可用、哪些仍然缺失,不做任何粉饰。

| 可用功能 | 引擎的窗口与生命周期;在固定并经过校验的 PocketBase 上实现的项目持久记忆;Memory、Backup、Storage、Projets、Git、Model Router、Context & Cache 和 Terminal 等部分;完整的 NewPi 外观(图标、名称、侧边栏) |
| 缺失功能 | 运行时未内嵌:Node 和 dsh 必须安装在机器上;未分发任何已签名或已公证的软件包;是否内嵌运行时的决定仍未确定 |
| 平台 | macOS 12 或更高版本。固定的 PocketBase 归档为 darwin_arm64:目前仅支持 Apple Silicon |
| 验证 | pnpm test 先运行 Rust 测试套件,再运行 257 个 JavaScript 测试;GitHub Actions 会重放相同的测试套件,外加 rustfmt 和 clippy |
| 许可证 | MIT,参见 LICENSE |

目录

- NewPi 是什么
- 前置要求
- 命令
- 工作原理
- 已验证的内容
- 目录结构
- NewPi 在哪里找到记忆 sidecar
- NewPi 在哪里找到运行时
- 环境变量
- 已知限制
- v1 中记忆功能的限制
- 待决断:是否内嵌运行时
- 贡献
- 安全
- 许可证

NewPi 是什么

NewPi 是一个原生 macOS 应用程序。它拥有窗口、生命周期、项目记忆以及引擎的启动;界面则是该引擎在本地回环上所提供的界面。

引擎未被修改:NewPi 是套在按原样安装的 DeepSeek Harness(dsh)外面的一个 Tauri 外壳。产品名称是 NewPi,凡是用户能看到的地方——启动画面、窗口、软件包、bundle 描述——显示的都是它。引擎在内部、日志中以及它所渲染的界面里保留自己的名称,因为实际运行的是它。
凡是带有名字的东西都会被重命名,而 NewPi 为了实现这一点,不修改引擎的任何文件:它挂载了第三个小插件 newpi-brand,该插件在 index.html 的渲染上有一个挂载点(tapIndex,Web 服务器将其描述为“结构化注入行无法表达的标记的逃生通道”)。

有两个元素带有引擎的名字,而每一个都需要不同的处理方式:

- 标签页和窗口的标题。 该插件替换所提供 HTML 中的 元素。这是窗口在任何脚本运行之前所显示的内容。但客户端在启动后会重写 document.title——实测:所提供的 HTML 写的是 NewPi,而几秒钟后 document.title 仍然写着引擎的名字。因此脚本会重新执行重命名,然后替换文档的 title 访问器,使后续的写入落到同一个值上。只有这个访问器被触及。
- 侧边栏的文字标识。 引擎用 SVG 绘制它:路径就是名字,因此没有任何字符串可以替换。不过客户端在包围它的元素上暴露了 data-slot="sidebar.brand.name"。一条仅作用于该 slot 的规则会隐藏路径,脚本则在旁边添加一个 NewPi 标签。
- 名字左侧的标记。 相邻的 slot sidebar.brand.mark 中情况相同。NewPi 在那里放置自己的鲸鱼:脚本将 指向 /newpi/whale.svg,这是插件自己从 assets/whale.svg 提供的一条路由。因此仓库中的 SVG 仍然是该图形的唯一副本——插件在配置中接收它,将其嵌入二进制文件,而启动补丁将其作为 YAML 标量传输(XML 声明被移除,因为 HTML 文档中的第二个头部会是一个错误)。

该标记在 28 px 的行中绘制为 28 px,而该资源是一个剪影:鲸鱼和 π,没有模糊的光晕,也没有“图标”版本中的装饰线条。这不是品味的选择,而是一种测量:在 24 px 时光晕和内部细节会混在一起,在 28 px 时简化剪影本身就能被辨识。因此详细版本只存在于 assets/logo.svg 中,用于应用程序图标,在那里它以 1024 px 渲染,没有这种约束。

在真实应用程序的浏览器中实测:document.title 为 NewPi,引擎的两条路径处于 display: none,NewPi 标签以 46×15 渲染,assets/whale.svg 通过 /newpi/whale.svg 以 28×28 加载,并且页面不产生任何错误。“HARNESS”一词不再出现在 DOM 中的任何地方。

应用程序图标

assets/logo.svg 是源文件:pnpm icon 将其栅格化为 assets/icon-source.png(1024 px,保留透明度),Tauri CLI 由此派生出所有尺寸,包括 .icns。该栅格化器是一个真正的渲染引擎,而不是重绘:该 logo 带有两个渐变、一条细边和一个光晕模糊的,自制的图标生成器只能近似。它只在这一步被调用,因此是可选而非必需的——不重新生成图标的仓库无需安装任何东西。

前置要求

| 工具 | 版本 | 备注 |
| Node.js | 20 或更高 | 启动 NewPi 引擎 |
| DeepSeek Harness (dsh) | 可用 | 引擎,全局安装,详见下文 |
| Rust | 1.77 或更高 | 由 rustup 提供 |
| Xcode Command Line Tools | 最新 | 提供 macOS 链接器 |
| pnpm | 9 或更高 | 管理 Tauri CLI |

命令

pnpm install # 安装 Tauri CLI 并复制 harness 模块用于测试
pnpm dev # 以开发模式启动 NewPi,支持 Rust 重载
pnpm build # 在 src-tauri/target/release/bundle 中生成 NewPi.app 和 .dmg 镜像
pnpm build:app # 仅生成应用程序,不含磁盘镜像
pnpm icon # 从 assets/icon-source.png 重新生成图标
pnpm test # 先运行 Rust 测试,再运行插件测试
pnpm storage # 磁盘占用,以及仅可重建的清理
pnpm test:pocketbase # 针对真实 PocketBase 二进制文件的端到端验证
pnpm test:backup # 针对真实二进制文件的备份、删除、恢复
pnpm verify:running # 对运行中的 NewPi 实例进行验收检查
pnpm verify:console # 用真实浏览器驱动 Memory 和 Backup 部分
pnpm verify:projects # 用真实浏览器驱动 Projets 部分
pnpm verify:projects-git # 挂载一个仓库和一个隔离的裸远端,并验证界面中的 Git 区域
pnpm verify:projects-capability # 真实引擎和真实界面:网络拒绝、允许、拒绝,无仓库也无网络

如果 pnpm install 在 EPERM: operation not permitted, chmod .../@deepseek-ai/cordis/bin.js 处失败,说明 node_modules 来自这些模块是指向 harness 的链接的版本:pnpm 拒绝修复指向项目外部的链接,并在脚本之前停止。安装后脚本现在会将它们复制到项目中。
只需清理一次即可:

rm -rf node_modules/@deepseek-ai && pnpm install

pnpm verify:console 不启动任何东西:它需要 NewPi 在启动时打印的 URL,包括令牌。pnpm dev 会以明文显示它,脚本会用它来在无窗口的 Chrome 中打开界面,点击这两个部分,并验证它们显示的内容:

NEWPI_WEB_URL="http://127.0.0.1:7318/?token=…" pnpm verify:console

pnpm dev 编译 Rust 二进制文件,在本地启动引擎,然后
在界面上打开原生窗口。首次编译会下载并构建 Rust 依赖,这需要几分钟。
后续编译不到一分钟。

pnpm build 生成一个即时签名的应用程序,可直接从 Finder 打开。

迭代时,请优先使用 pnpm build:app。 构建磁盘镜像有两个会在用户面前多次显示 NewPi 的副作用:链接器会在 bundle/macos/ 中生成一个临时的 .app,而 hdiutil 会在 /Volumes/dmg.XXXX/ 下挂载一个工作镜像。两者都带有与已安装应用相同的 bundle 标识符,因此 Spotlight 和 Launchpad 会把它们都索引为 NewPi。
如果没有 --bundles dmg,这两者都不会存在。

pnpm build 会尽可能清理它留下的内容:临时副本会被删除并注销。然而,
已卸载卷的条目无法再通过 lsregister 移除——它会在重启后消失。标记文件 src-tauri/target/.metadata_never_index 还可以防止 Spotlight 索引构建目录。要将其分发到其他机器,还需要使用 Apple 证书签名并进行公证。

在 tauri.conf.json 中,字段 bundle.macOS.signingIdentity 的值为 -。
该值会强制对整个 bundle 进行 ad hoc 签名。如果没有它,只会保留来自链接器的二进制签名,而添加 Contents/Resources/icon.icns 会使其失效。使用 codesign -v 对 NewPi.app 验证签名:它应返回 valid on disk。

工作原理

运行时仍是独立进程

引擎的 web 配置文件会在 127.0.0.1 上启动一个 HTTP 服务器,并输出一行可用性信息,其中包含一个带有启动令牌的 URL。服务器会用该令牌换取一个签名 cookie,然后重定向到干净的页面。之后所有 API 和 WebSocket 流都通过该 cookie 进行身份验证。

NewPi 在不做任何修改的情况下利用这一机制:

1. 它选择一个空闲端口,优先使用 7317。稳定的端口可以保持页面来源稳定,因此界面在两次启动之间会保留其本地存储。
2. 它在专用进程组中启动 node web --no-open --port 。
3. 它读取标准输出,并等待 dsh web: 这一行。
4. 它打开一个新窗口,将该 URL 作为首次加载,然后在页面渲染完成后移除启动画面。
5. 关闭时,它向进程组发送 SIGTERM,六秒后再发送 SIGKILL。由 harness 启动的持久 shell 和后台任务会随之停止。

在启动期间,窗口会显示一个本地启动画面,展示进度;如果运行时没有响应,则显示错误详情。

为什么 URL 必须是首次加载

这是最容易破坏的一点,值得白纸黑字写清楚。

会话 cookie 使用 SameSite=Strict 设置。如果从本地启动画面导航到带有令牌的 URL,该导航来自与 127.0.0.1 不同的来源。随后的重定向就会被视为跨站,浏览器会保留该 cookie,运行时则返回其拒绝页面:

需要 dsh web 身份验证;请重新打开 dsh web 打印的 URL。

症状具有误导性:cookie 确实存在于浏览器存储中,因此一个天真的检查会错误地得出结论认为一切正常,而窗口却没有显示任何界面。

直接在该 URL 上创建的窗口没有这个问题,因为加载没有来源页面。这正是 dsh web 在打开默认浏览器时所做的事情,也正是出于这个原因,src-tauri/src/runtime.rs 中的 open_interface 会构建一个窗口,而不是从启动屏幕进行导航。

没有远程服务器

运行时是本地,界面由这个本地运行时提供服务,没有任何数据经过第三方。唯一的网络流量是 harness 自身向模型发出的流量,与命令行中完全一样。

项目的持久记忆

一个每次会话都从零开始的代理会重新学习同样的东西。因此 NewPi 为每个项目添加了一份记忆:约定、决策、已解决的 bug 及其确认的原因和证据,以及不应重蹈的教训。它能在会话之间、NewPi 更新之后依然存在,并且不会离开本机。

三个部分,仅此而已:

1. 本地 sidecar 中的 PocketBase。 官方 macOS ARM64 二进制文件按版本和 SHA-256 指纹固定(scripts/pocketbase-pin.mjs),仅解压一次到应用程序的状态目录中,然后在每次启动时按原样重新启动——没有任何网络访问。它监听 127.0.0.1 上的单一端口,该端口在启动时被选择并验证。
2. pocketbase-memory 插件,它负责记忆的连接、创建、搜索和删除,并发布 ctx.pocketbaseMemory 服务。
3. memory-tools 插件,它将该服务以三个工具的形式暴露给模型:remember(content, kind)、recall(query, kind?, limit?) 和 forget(id)。

这两个插件写在 plugins/ 中,编译进二进制文件,然后在每次启动时部署到状态目录中。它们通过 NewPi 重新生成的启动补丁(launcher.patch.yml)挂载,这是注入仅在启动时才存在的配置(sidecar 的端口)的唯一方式。

开发循环:dev.json

将插件编译进二进制文件对于发布版本来说是合适的折中——一个不会丢失任何文件的包——但对于迭代某个插件的人来说则是不合适的:一行 JavaScript 就要付出完整重建应用程序的代价。状态目录中的一个可选文件让循环变得像普通编辑器一样:

{ "pluginsDir": "/Users/you/src/NewPi/plugins", "reload": true }

- pluginsDir:启动补丁会为该文件夹提供的每个插件挂载 //index.js,并且部署不再拥有这些插件——它永远不会重写它们,也永远不会删除它们。仓库成为源头。
- reload:一个监视器每秒测量一次树,并在写入稳定后重启运行时,因此保存就够了。
它默认是禁用的:重启运行时会终止它正在服务的会话,而一个同时在该窗口中与 agent 对话的开发者,不希望看到一次回复被一次保存切成两半。

没有该文件时,一切不变:二进制文件仍然是来源。一个无法读取或指向不存在文件夹的 dev.json 会被报告并忽略——它只会让循环失效,而绝不会让应用程序失效。

控制台:不离开 IDE 即可执行

Console 部分是第六个注入面板,与 Memory、Backup、Storage 和 Projets 并列:侧边栏底部的一行、一个面板,以及一次在项目文件夹中执行的一条命令——git status、pnpm test、cargo check、pnpm storage,一切不需要交互式终端的东西。

角色划分与其他地方相同:页面发送一行命令,仅此而已,宿主决定文件夹(由补丁行携带的 workspace,否则为当前项目),在其自己的进程组中启动 /bin/zsh -lc,并以 NDJSON 流式传输输出——每段文本一帧,然后是一个带退出码、信号、截断和持续时间的 exit 帧。颜色、OSC 标题和回车重绘在宿主上被简化为文本,因此该面板是一个 而不是模拟器:没有任何转义内容到达文档。

使用中有两个重要细节:停止按钮会取消请求,宿主会杀死整个进程组——一个误启动的 pnpm dev 不会在面板关闭后继续存活;而一条不返回的命令会在 30 分钟后被停止,并显示原因。

这不是终端:没有 PTY,因此没有 vim,没有 htop,没有任务控制。这是第二阶段——它需要嵌入一个模拟库,而且不值得去半吊子地模仿一个真正的 TTY 做得很好的事情。

项目之间的隔离

所有项目只有一个 PocketBase 集合。隔离依赖于一列 project_id 和三条规则:

- project_id 来自已打开的项目,绝不来自工具参数:这三个工具只暴露 content、kind、query、limit 和 id。该值由项目的 cordis.yml 固定,然后由 Project Model 的记录(memoryNamespace)携带,NewPi 在启动 harness 之前会重新读取它。
- 作用域在 harness 启动之前就在服务配置中设定,并被复制到每个 PocketBase 过滤器中——绝不事后应用。
- 因此,来自另一个项目的标识符既找不到,也与不存在的标识符无法区分:forget 会回答 deleted: false,而不删除任何内容。
要为项目范围固定标识,请创建 /.newpi/cordis.yml:

memory:
project_id: twin

如果没有这个文件,NewPi 会从文件夹名称(twin、mon-projet)推导出一个稳定标识符,并在启动日志中注明。只要项目重要,就应固定该值:否则,位于两个不同路径下的两个同名文件夹将共享同一份记忆。

没有打开任何项目时,就没有任何作用域。 harness 始终需要一个工作文件夹,因此它会在个人文件夹中启动——但该文件夹只是启动兼容性所需,并不是一个项目:NewPi 不会为它分配名称、命名空间或记忆。此时 Memory 区域会显示“Aucun projet ouvert”,并且既不读取也不写入任何内容。已记录在旧有派生命名空间(例如个人文件夹名称)下的记忆会保持原样,不会被静默迁移。

智能体应记录什么

工具描述中承载了这条规则,因为这是模型一定会读取的唯一位置:记录结论,而不是活动。一个已解决的 bug 应连同其四行内容一起写入——症状、已确认的原因、修复、测试或证据——并且原因只有在确认后才写入。工作过程中的想法、未经验证的假设以及对当前请求的复述都不应出现在其中。

数据的确切位置

~/Library/Application Support/NewPi/
├── launcher.patch.yml 重新生成的启动补丁 ├── backups/ 用户的备份 │ └── newpi-memory-20260912-231541.zip 归档 + 清单 ├── plugins/ 从二进制文件部署的插件 │ ├── pocketbase-memory/ 服务、客户端、环境 │ ├── memory-tools/ 三个工具 │ ├── memory-console/ 两个区域、桥接、格式 │ ├── storage-console/ Storage 区域、目录、度量 │ └── node_modules -> ~/.dsh/profiles/node_modules └── pocketbase/ ├── pocketbase 提取并验证过的官方二进制文件 ├── version 固定版本的二进制文件 ├── credentials 本地超级用户,权限模式 600 ├── pb_migrations/ │ └── 1789230000_create_memories_collection.js └── pb_data/ 数据库 ├── data.db memories 集合 ├── auxiliary.db ├── types.d.ts └── backups/ sidecar 的快照,临时性

backups/ 文件夹是 pocketbase/ 的同级目录,而不是 pb_data/ 的子目录:PocketBase 会清理它自己所拥有的快照文件夹,而用户请求的备份不应被 sidecar 删除。

这些内容都不在 bundle 中:NewPi.app 的更新会替换捆绑包,并保持这些文件不变。要从头开始,请删除 pb_data/;要在不删除任何内容的情况下禁用记忆,请使用
NEWPI_MEMORY=0 启动。

界面的两个部分

记忆是为智能体而存在的,但它属于用户:Memory 和 Backup 这两个部分的存在,是为了让用户无需借助工具就能读取、筛选、删除和备份它。

它们被注入到运行时提供的界面中,方式与产品名称完全相同:由 tapIndex 向文档头部添加一个 和一个 。引擎的任何文件都不会被改动,而且该脚本不会替换任何东西:它会在侧边栏底部的操作容器中添加条目,并在界面之上打开一个面板。如果这个位置不存在,浮动按钮就会接替:一个部分应当是无法找到的,而绝不应是缺失的。

位置是底部,而不是引擎的面板列表。后者只有在有东西注册到其中时才会被渲染,而外壳会在其中绘制已注册的组件,在其自身那一行的字形内部:注入到那里的一行已经发现自己被嵌套在另一个插件的按钮中,无法阅读,并且吞掉了它的点击。面板列表是给外壳所知道的组件用的;注入的按钮则去底部。

Memory 和 Backup 这两个部分就在那里,如下所述。这与存储控制台是同一套机制:注入一个 和一个 ,并在界面之上打开一个面板。

注入是幂等的:harness 在首次加载时渲染其 tapIndex,然后在客户端于其中导航时再次渲染,而一个不检查就 splice 的 tap 会把该部分的脚本放进页面两次——这曾产生两个面板,其中一个的状态从未被构建过。

每个部分做什么:

- Memory —— 项目记忆的数量、按 kind 的分布以及最后一次写入的日期;对内容进行搜索;按 kind 筛选;带 kind、日期、标识符和预览的分页列表;打开一条记忆以阅读其完整内容;仅在明确确认后删除它;以及一个空状态,说明当项目没有记录任何内容时该做什么。
- Backup —— 创建备份(位置在保存面板中选择),恢复备份(选择归档,显示其元数据,然后明确确认替换),打开备份文件夹,以及最近本地备份的列表,包含其日期、大小和清单摘要。

两个文件面板从何而来

归档选择是页面中的一个 :它是NewPi 窗口自己打开的系统面板,附着于该窗口,随后归档会在页面中被读取,然后交回宿主(backup.upload,在 base64 中,位于一个中转文件夹 .incoming/ 内)。这种经由字节的绕行,是一个属于窗口的面板所付出的代价:宿主永远不会收到用户本应输入的路径,而且没有任何东西离开这台机器。

备份的位置不能来自页面:网页没有保存面板。因此 NewPi 通过 osascript 向 macOS 请求,这正是引擎为其自己的文件夹选择器所做的事情——dsh-host-directory-picker-native 在 darwin 上调用 osascript -e 'choose folder …',并以相同的 -128 代码识别取消。这个面板的窗口属于 osascript 进程,而不属于 NewPi:这是拒绝给页面提供 Tauri API 所导致的、被坦然接受的结果。没有面板的归档仍然可行(pick: false),而没有图形会话的环境会明说这一点,而不是静默失败。

下面两张截图来自正在运行的应用程序,由 pnpm verify:console 在一个无窗口的 Chrome 上拍摄,该 Chrome 点击了两个部分。第一张显示一个包含三条记忆的项目;第二张显示恢复的确认屏幕,其中包含所选归档的元数据,以及说明替换会带走什么的警告。

Memory 部分:项目的三条记忆、搜索和按 kind 过滤

Backup 部分:备份的元数据和恢复确认

桥梁:单一路由,位于 /api 之后

只有一个入口点,POST /api/newpi.console,注册在 ctx.connection.fetch 上。与 ctx.webServer 的区别就是安全性的全部故事,而且值得再次强调,因为两者看起来可以互换,但实际上并非如此:webServer 路由在任何身份验证之前被分发,而 connection.fetch 路由则位于与 /api 其余部分相同的来源闭包和相同的签名会话 cookie 之后。一个可以删除记忆的入口点,不应该被任何知道如何打开本地套接字的东西访问到。

十个操作,分为两族:

| 操作 | 它做什么 |
| memory.status | 记忆的数量、按 kind 的分布、最后一次写入 |
| memory.page | 列表的一页,按 kind 或搜索过滤 |
| memory.read | 一条记忆的完整内容 |
| memory.delete | 在明确确认后删除 |
| backup.status | 本地归档列表及其清单 |
| backup.create | 将备份写入所选位置 |
| backup.upload | 接收在页面中读取的归档,以 base64 编码 |
| backup.inspect | 在恢复之前读取归档的清单 |
| backup.restore | 在明确确认后替换数据库 |
| backup.reveal | 在 Finder 中打开备份文件夹 |

项目的范围不来自浏览器:它被传递给插件,在启动补丁的那一行,而提供程序从服务中读取它,而不是从工具参数中读取。验证器会拒绝任何操作未声明的键,因此请求中的 project_id 会在操作看到它之前就被拒绝。

sidecar 的密码和 URL 从不经过这条路由:它们在运行时的环境中传递,界面无法读取它们。而控制台则会收到其清单所需的四个路径——backupDir、snapshotDir、dataDir 以及两个版本——因为它们都不是机密。

备份的格式

NewPi 备份是一个单独的 zip 归档:

newpi-memory-20260912-231541.zip
├── manifest.json 这个归档是什么,以及它包含什么 └── pb_data/ ├── data.db 记忆 ├── auxiliary.db └── types.d.ts

pb_data/ 中的文件不是被复制的:它们来自一个由 PocketBase 自己通过其备份 API 生成的快照。这正是该格式能够承诺一致性的全部原因——NewPi 从不在服务器写入时读取 data.db,也不会为此停止服务器。sidecar 的快照在导出后即被删除,以免 pb_data/backups 每备份一次就多出一个文件。

清单是让恢复能够被安全提议的关键:

{
"format": "newpi-memory-backup",
"format_version": 1,
"created_at": "2026-09-12T21:15:41.211Z",
"created_by": { "name": "NewPi", "version": "0.1.0" },
"pocketbase": { "version": "0.40.4" },
"source": { "project_id": "twin", "collection": "memories" },
"contents": {
"entries": ["pb_data/data.db", "pb_data/auxiliary.db", "pb_data/types.d.ts"],
"bytes": 1003107,
"sha256": { "pb_data/data.db": "…", "pb_data/auxiliary.db": "…", "pb_data/types.d.ts": "…" }
},
"memories": { "total": 4 },
"reader": { "min_format_version": 1, "max_format_version": 1 }
}

三种拒绝,每一种都有自己的提示语:

- 格式未知或无法读取——«这个文件不是 NewPi 备份»;
- 格式过新——format_version 高于此版本所能读取的版本: «备份过新……请更新 NewPi 以恢复它»;
- PocketBase 过新——数据库是由比内置版本更新的 PocketBase 写入的:需要至少同样新的 NewPi 版本。由更旧的 PocketBase 写入的备份会被接受,并附带一条警告,说明迁移将在重启时应用。

恢复按顺序执行的操作

恢复会替换此机器上所有项目的记忆。这是一个带回滚的事务,而不是文件复制:

1. 归档会被验证——zip 完整性、清单、版本、SHA-256 指纹、SQLite 头——在触碰任何东西之前;
2. 当前状态的安全备份会被创建并保留在
1. 备份文件夹中,文件名为 …-avant-restauration-….zip;
2. 在整个操作期间,内存写入被阻止:在此期间到达的 remember 会等待,它绝不会写入正在被替换的数据库;

4. l'archive est reconstruite dans la disposition que la restauration de
PocketBase attend, puis remise au sidecar par sa propre API — qui redémarre le serveur autour de la nouvelle base, sur le même port et le même pid ;
5. le résultat est vérifié en relisant la base : le nombre de souvenirs est
comparé à celui du manifeste, et le total du projet est relu. Une
restauration qui ne correspond pas est annulée, et l'état précédent est remis
en place depuis la sauvegarde de sécurité.

Un détail mesuré, et la raison d'un quart d'heure de mise au point : après
POST /api/backups/{clé}/restore, PocketBase répond 204 environ une seconde
avant que la nouvelle base soit en place, et jusque-là il répond encore
l'ancienne. Une restauration vérifiée dès le retour de l'appel vérifiait donc la
mauvaise base. NewPi attend maintenant que le fichier data.db change
d'identité, puis que le compte corresponde, avant de conclure.

Le dashboard et l'API ne sont pas exposés

Le sidecar est lié à 127.0.0.1 par un littéral, jamais à 0.0.0.0, et sans
TLS ni redirection. Le dashboard intégré et l'API REST répondent donc sur la
boucle locale uniquement, et la collection refuse tout appel non authentifié
(403). Le seul lecteur est le harness, avec un superutilisateur généré sur
cette machine et stocké en mode 0600 ; le mot de passe voyage dans
l'environnement du harness, jamais dans un fichier de configuration.

Mem0

Mem0 n'est pas supprimé. Si un profil DSH déclare un paquet Mem0, NewPi écrit
une ligne disabled: true dans son patch de lancement, ce qui le débranche sans
toucher aux fichiers ni au profil. Pour revenir en arrière, retirez cette ligne
— ou n'utilisez pas le patch de lancement — et Mem0 se remonte tel quel. Sur une installation qui ne déclare pas Mem0, aucune ligne n'est écrite : le patch reste honnête sur ce qu'il fait.

La section Storage : où est passé le disque

Un target/ de Tauri, un cache npm et un disque de simulateur ne se signalent
pas. Ils grossissent de quelques gigaoctets par expérimentation, rien ne les fait
jamais tourner, et le premier symptôme est un disque plein sans raison visible.
L'audit qui a produit cette section est dans docs/storage-audit.md :
sur cette machine, les 39 cibles suivies pèsent 107,89 Gio, dont
49,11 Gio reconstructibles sans perte. NewPi lui même — application, base mémoire, sauvegardes et sessions comprises — tient en moins de 40 Mio.

La section Storage est le troisième panneau injecté, à côté de Memory et
Backup, et pnpm storage fait la même chose sans fenêtre :

pnpm storage # tableau complet, groupé par portée, + politique
pnpm storage --scope newpi # seulement le projet et son moteur
pnpm storage --preview npm-cacache # cible, taille allouée, taille logique, coût
pnpm storage --clean newpi-target --confirm newpi-target

Chaque cible du catalogue porte un rôle — build jetable, cache reconstructible, journaux, sessions, mémoire, sauvegardes, état applicatif, données — et une classe :

- reconstructible : la sortie d'un compilateur, un cache de paquets, une
image de bac à sable. La supprimer coûte du temps et de la bande passante,
jamais une information. Ce sont les seules cibles que la section sait
supprimer.
- protégé : une conversation, un souvenir, une sauvegarde, l'état d'une
application tierce. Aucun chemin de code ne mène à leur suppression.

Deux chiffres sont affichés par cible, et la distinction compte : la taille
allouée (st_blocks * 512, ce que le disque dépense) et la taille
logique (st_size, ce que le fichier annonce). Un fichier creux — une image
de bac à sable, un disque de simulateur — vaut des gigaoctets d'un côté et des mégaoctets de l'autre ; n'en montrer qu'un est exactement ce qui transforme une lecture à « 70 Go » en mystère. Un lien symbolique n'est jamais suivi, et un inode à plusieurs liens n'est compté qu'une fois — sans quoi target/ se lit à 6,4 Gio au lieu de 3,64 Gio. La mesure a été confrontée à du -sk et donne le même octet.

Ce que la section ne peut pas faire

Trois verrous, et le troisième est le vrai.

1. Le catalogue décide, pas la requête. Le navigateur ne peut nommer qu'un
identifiant de cible ; un paramètre qu'une action ne déclare pas est refusé,
donc aucun chemin ne peut entrer par là.
2. Le chemin est revérifié sous les racines du lancement. Le catalogue est
une donnée : un bogue dedans ne doit pas devenir un rm -rf $HOME. La cible
est résolue puis comparée à home, workspace, l'état de l'application et
$DSH_HOME, après path.resolve — un .. ou un parent symbolique ne fait pas sortir du périmètre.
3. Supprimer une session, une mémoire ou une sauvegarde n'a pas de fonction.
Ce n'est pas une confirmation, c'est un chemin de code absent :
runSafeCleanup refuse un identifiant protégé avant même de résoudre un
chemin. Une boîte de dialogue est à une frappe de profondeur, et une frappe
de profondeur s'apprend ; une fonction qui n'existe pas ne se clique pas. Les
souvenirs se suppriment dans Memory, un par un, et les sauvegardes ne se
suppriment nulle part.

Une suppression reconstructible, elle, demande de recopier l'identifiant de la
cible dans un champ, avec la cible et la taille affichées au dessus — dans l'interface comme dans pnpm storage --clean … --confirm …. Le serveur revérifie cette recopie, puis revérifie que la taille mesurée correspond encore à celle qui a été montrée : une compilation qui grossit entre l'aperçu et la confirmation fait échouer la suppression (409) plutôt que d'effacer autre chose que ce qui était annoncé.

Ce qui est reconstructible, et ce que cela coûte

| Mode | Cibles | Coût |
| remove-directory | src-tauri/target, node_modules, .pnpm-store, DerivedData, symboles iOS, caches pnpm et WebKit | le propriétaire recrée le dossier |
| clear-contents | ~/.npm/_cacache, ~/.npm/_npx, registre cargo, ~/.cache, Gradle, pub, Dart, Homebrew, node-gyp, Playwright, SwiftPM, microsandbox | le dossier reste, son contenu se retélécharge |

La politique de rétention

Elle est déclarée une fois, dans RETENTION_POLICY
(plugins/storage-console/catalog.js), et rendue par la section à côté des
chiffres auxquels elle s'applique.

| Rôle | Automatique | Durée | Budget |
| build jetable | aucune | — | 8 Gio |
| cache reconstructible | aucune | — | 12 Gio |
| journaux | rotation | 7 jours | 50 Mio |
| sessions | jamais | 30 jours, 50 dernières par projet | — |
| mémoire PocketBase | jamais | — | — |
| sauvegardes | jamais | — | — |
| état applicatif | jamais | — | — |

Le budget n'est pas appliqué de force : c'est un seuil au delà duquel la ligne
affiche ⚠ hors budget. Un budget qui supprime tout seul serait un budget qui
supprime la chose qu'on est en train d'utiliser. Le seul rôle rotatif sans
demande est le journal, parce qu'un journal tronqué ne perd aucun état.

Les sauvegardes PocketBase sont hors d'atteinte

/backups/ est un frère de pocketbase/, pas un enfant de pb_data/ :
PocketBase élague le dossier d'instantanés qu'il possède, et une sauvegarde
demandée n'a pas à disparaître avec lui. La section Storage ne supprime rien sous
backups/ ni sous pb_data/backups/, et la section Backup ne fait que créer,
lister, inspecter et restaurer. pocketbase-memory est le seul écrivain de la
base ; storage-console ne la nomme que pour l'afficher.

Le Model Router : choisir le modèle sans le coder dans les agents

NewPi n'a pas de registre de providers et n'en veut pas : le harness en a
exactement un, ctx.llm, un registre d'adaptateurs avec une API d'appel en
flux, et la couche agent en a exactement une valeur de sélection, le triplet
{ provider, model, reasoningEffort } dont un agent construit ses requêtes. Ce
qui manquait était la politique : quel triplet une requête reçoit, selon un
mode et un rôle. Elle vit maintenant à un seul endroit, dans le greffon
model-router, et un agent n'a plus à connaître le nom d'un provider pour
demander un genre de travail :

const selection = ctx.modelRouter.forRole('coding');
for await (const chunk of ctx.modelRouter.stream('review', request, { sessionId })) { /* … / }

Les trois modes

| Mode | Ce qui décide | Changer à chaud |
| manual | la configuration, et rien d'autre | refusé : c'est la configuration qui décide |
| switch | une sélection active, remplaçable | switchTo({ provider, model }) |
| auto | le rôle demandé | switchTo(..., { role }) pour surcharger un rôle |

Dans les trois modes, la sélection par défaut du processus
(agentDefaultModel, le service dont le harness crée ses agents) suit la route
active du mode : un agent qui ne demande aucun rôle démarre donc là où le plan
l'a placé. auto expose en plus les routes par rôle via forRole().

L'entrée Adaptive du sélecteur

Le sélecteur de l'interface liste ce que ctx.llm annonce. auto était déjà une
politique, mais rien dans l'interface ne permettait de la choisir : il
fallait être un agent pour demander un rôle. Le routeur enregistre donc sa propre
route, newpi-router/adaptive, affichée Adaptive — « Automatically balances quality and cost ».

Choisir cette entrée, c'est choisir la politique. Le rôle est résolu depuis la
requête elle-même, puis la requête est réécrite sur la route réelle avant
l'envoi* :

| Signal dans la requête | Rôle |
| purpose: 'compaction' ou 'session-title' | fast — du volume, pas de la difficulté |
| des schémas d'outils | coding |
| rien de tout cela | default |

La réécriture est ce qui garde le dossier honnête : le harness journalise
request.provider / request.model au moment où il assemble le message, donc
c'est le modèle qui a répondu qui entre dans l'historique — et dans toute projection de coût qui price par route —, jamais la pseudo-route. Le fallback du rôle s'applique quand la route primaire échoue avant le premier morceau, et la réécriture suit : un repli est attribué à la route qui a répondu.

La fenêtre de contexte annoncée pour Adaptive est la plus petite des routes
du plan, et les modalités leur intersection : n'importe lequel des rôles peut
répondre, donc la seule promesse tenable est l'enveloppe commune.

La configuration

Elle vit dans le cordis.yml du projet, à côté de la portée mémoire, et elle
est lue avant le démarrage du harness : un plan invalide est signalé dans la
langue du fichier que l'utilisateur a édité, et le routeur n'est alors pas monté
du tout — perdre un routeur coûte moins qu'une fenêtre.

model_router:
mode: auto
roles:
fast:
provider: deepseek
model: deepseek-chat
coding:
provider: deepseek
model: deepseek-v4
reasoning: high
fallback:
provider: glm
model: glm-5.3
default:
provider: deepseek
model: deepseek-chat
requirements:
coding:
tools: true
max_context: 64000

- mode est obligatoire, et chaque mode exige ce qui le rend utilisable :
manual exige manual:, switch exige active:, auto exige
roles.default.
- Les rôles sont fast, coding, reasoning, research, review et
default. Un rôle non mappé résout vers default ; un rôle hors vocabulaire
est refusé, parce qu'une faute de frappe ne doit pas se cacher derrière une
route par défaut.
- Une clé inconnue est refusée, jamais ignorée : un fallback mal orthographié
est une route sans fallback.
- Aucun credential n'a sa place ici. Une clé qui ressemble à un secret
(api_key, token, password…) fait échouer la lecture avec un renvoi vers le mécanisme existant, $DSH_HOME/settings.yaml et l'environnement. C'est le harness qui authentifie ; le plan ne fait que nommer des routes.
- Sans bloc model_router, aucun routeur n'est monté et le comportement du
harness est exactement celui d'avant la fonctionnalité.

Le fallback

Un rôle peut déclarer une route de repli. Elle n'est utilisée que sur un échec
d'indisponibilité du provider — provider ou modèle temporairement indisponible, délai dépassé, quota ou limite de débit, erreur réseau, statut 5xx — et seulement avant qu'un seul morceau ne soit parvenu à l'appelant : redémarrer après une sortie partielle la dupliquerait. Un échec fonctionnel ou logique du modèle (INVALID_ARGS, CONTEXT_WINDOW_EXCEEDED, AUTH, EMPTY_RESPONSE, abandon) ne déclenche jamais de repli : changer de provider ne le corrigerait pas et masquerait la cause réelle. Le classement est explicite et échoue en fermé : un code inconnu ne replie pas. Chaque repli est journalisé avec sa raison.

Le bail de modèle

Toute opération qui passe par stream() prend d'abord un bail : sa route
est figée au moment où elle commence. Un switchTo(), un setMode() ou une
surcharge de rôle ne s'appliquent donc qu'aux opérations suivantes. Un bail est
critique par défaut, et un switchTo() est alors refusé tant qu'il court — c'est ce qui empêche une étape atomique d'être annoncée sur un modèle et terminée sur un autre. switchTo(..., { force: true }) est l'échappatoire explicite, et il ne change toujours pas la route du bail en cours.

Les capacités

requirements déclare ce qu'une tâche exige, et une route peut déclarer ce
qu'elle sait faire. Ce que l'adaptateur publie (contextWindow,
inputModalities, efforts de raisonnement) est dérivé automatiquement ; tools
n'est pas publié par le harness et doit donc être déclaré. Une exigence est un
plancher : seul true en énonce un, et false veut dire « pas nécessaire », pas
« interdit ». Une capacité inconnue n'est pas un échec — un adaptateur qui n'annonce rien ne doit pas rendre toutes les routes incapables — mais elle est rapportée. auto écarte une route dont l'incapacité est prouvée et choisit le repli s'il convient ; si aucune route ne convient, la résolution refuse au lieu d'exécuter silencieusement un modèle inadapté.

Observabilité

Chaque appel — routé par le service ou non — produit une entrée de journal qui porte le mode actif, le rôle demandé, le provider et le modèle utilisés, le repli éventuel et sa raison, et les identifiants de session et d'opération. Le greffon s'accroche à llm/stream, le seul point qui voit aussi les requêtes qu'il n'a pas construites, pour les observer sans les rediriger. Une entrée ne peut pas porter de prompt ni de credential : sa forme est fixée par RECORD_KEYS, et tout diagnostic qui y entre est d'abord nettoyé (sk-…, Bearer …, clé = valeur, longues suites opaques). L'API est prête pour une UI sans en imposer une : describe() rend le mode, la route active, chaque rôle, les surcharges et les baux en cours ; onCall() et recentCalls() donnent le flux d'appels.

Le Context & Cache Manager : nommer le contexte, mesurer le cache, ne pas le simuler

Un prompt n'est pas un bloc homogène : un long préfixe stable (les règles du
harness, la persona, les instructions du projet) part à chaque étape et ne bouge
presque jamais, un résumé de handoff repose sur l'historique compacté et ne
change qu'à une compaction, et la queue — le message courant, les derniers résultats d'outil — change à chaque appel. Le cache de prompt d'un provider ne peut être réutilisé que sur la partie qui n'a pas bougé. Le greffon context-cache-manager répond donc à « qu'est ce qui a changé depuis le dernier appel », puis à « le provider l'a t il facturé comme un hit ».

Les cinq couches

| Couche | Stabilité | Source dans le harness |
| core | stable | les sections du prompt système assemblé |
| project | semi stable | les contributions de contexte d'exécution |
| handoff | semi stable | le résumé de la dernière compaction terminée |
| session | dynamique | l'historique avant le message courant |
| live | dynamique | le message courant et les résultats d'outil qui le suivent |

Chaque couche est réduite à un digest, un compte et les noms qui l'ont
composée. Aucune couche ne conserve le texte du prompt : c'est ce qui permet de
journaliser la télémétrie sans qu'un secret du projet n'y entre jamais.

Deux versions

- contextVersion (ctx_v1, ctx_v2…) nomme le préfixe cacheable : core + project + handoff. Il n'avance que si le digest de ce préfixe change. Deux appels de même version sont deux appels dont le cache provider peut être réutilisé ; un changement de version est le moment où un miss est attendu plutôt que surprenant. session et live en sont exclus : ils changent à chaque appel, et une version qui change à chaque appel ne nomme rien.
- handoffVersion (handoff_v1, handoff_v2…) nomme le handoff figé. Il avance une seule fois par compaction terminée, et pas avant : entre deux compactions, l'enregistrement de handoff est immuable. Une compaction en échec ne valide rien — le handoff précédent reste courant.

La télémétrie par appel

Chaque appel produit une entrée qui porte le provider, le modèle, l'effort, la
session, l'opération, l'objet de l'appel (purpose), les deux versions, l'issue,
l'usage rapporté, les faits de cache dérivés, et le digest des cinq couches. Sa
forme est fixée par RECORD_KEYS : une entrée ne peut pas gagner un champ qui
porterait un message ou un credential, et tout diagnostic est nettoyé avant
d'entrer (sk-…, Bearer …, clé = valeur, longues suites opaques).

Pas de faux cache

Le manager ne stocke aucun prompt, ne sert aucun préfixe, n'estime aucun hit.
Les seuls nombres qu'il rapporte sont des compteurs émis par un adaptateur, ou
de l'arithmétique sur ces compteurs. Le harness normalise déjà chaque provider
en compteurs disjoints : inputTokens (non caché), cacheReadTokens
(servi depuis le cache), cacheWriteTokens (écrit). Le manager en dérive
promptTokens = input + read + write, missTokens = input et
hitRatio = read / promptTokens.

Un provider qui n'expose aucune métrique de cache produit available: false et
une raison — une absence de donnée, jamais une erreur, et jamais un zéro fabriqué. Les adaptateurs livrés qui exposent des compteurs : DeepSeek (prompt_cache_hit_tokens / prompt_tokens_details.cached_tokens, sans champ d'écriture) et pi ai (cacheRead, cacheWrite, omis quand nuls). Tout autre adaptateur, ou un adaptateur simulé, reste simplement « inconnu ».

L'API

ctx.contextCache expose l'état courant (current(), snapshots()), les
versions (versions()), le journal (recentCalls(), onCall()), les
statistiques (cacheStats()), la température par route (temperature(),
temperatures()), et un instantané complet pour une UI (describe()). Pour le
Model Router, hintsFor(provider, model) et allHints() rendent tout ce qu'une
politique consciente du cache aurait besoin de savoir : température
hot/warm/cold/unknown, statistiques, et versions du dernier appel sur la
route. La température est volontairement prudente et sensible au temps : le
cache d'un provider expire, donc un ratio mesuré il y a une heure ne rend pas le
prochain appel hot.

Rien de tout cela ne décide d'une route. La politique AUTO du Model Router
est intacte : les données sont préparées et exposées, et aucun appel ne les lit.
Le manager se monte par une ligne vide du patch de lancement, sans dépendance
injectée : un déploiement qui ne configure rien se comporte exactement comme
avant la fonctionnalité.

Le Project Model : un projet nommé une fois, lu par tout le reste

Le harness est lancé avec un seul dossier de travail, et rien en lui ne porte
la notion de « projet » : un dossier est un workspace, une session a un cwd,
et c'est tout. Le greffon project-model ajoute la pièce qui manquait — le projet courant, unique par construction — et les quelques garde-fous qu'un futur éditeur ou un terminal consultera.

Il ne réinvente pas un workspace. ctx.workspaceRegistry reste le registre
durable de ce qu'est un dossier ; un projet est ce workspace plus ses
métadonnées, et le service crée l'enregistrement de workspace plutôt que de le
remplacer. Il ne fait pas non plus une seconde mémoire : memoryNamespace est
le même project_id que celui avec lequel le backend a été configuré, donc un
projet nomme un seul espace.

Le registre

Un petit document JSON sous le répertoire d'état, projects.json : les projets
récents (20 au maximum), lequel est courant, et pour chacun la racine
canonique, les réglages, les sessions associées (200 au maximum), le namespace
mémoire, l'état de handoff et un petit état d'interface (lastSessionId,
lastTab, recentFiles). Chaque écriture passe par un fichier temporaire
renommé, comme le patch de lancement. La lecture est totale : un document
corrompu coûte la liste des récents, jamais le lancement, et l'avertissement
est journalisé.

Les cinq capacités, et la porte

Un projet déclare ce qu'il autorise : readWorkspace, writeWorkspace,
terminal, git — actives par défaut, parce qu'un outil de code local qui ne pourrait pas lire son propre dossier ne servirait à rien — et network, désactivée par défaut parce que c'est la seule qui ajoute de la portée. Un
assertCapability() refuse en 403 une capacité non accordée, et
withCapability(cap, body) fait de la vérification et du travail un seul
appel : la porte ne peut pas être oubliée entre les deux.

La zone Git applique une double autorisation. La capacité git couvre ce
qui se passe sur la machine — détecter le dépôt, lire le statut, montrer un diff, créer un commit local. Toute action qui quitte la machine — fetch, push, et la mise à jour en avance rapide déclenchée après un fetch — exige network en plus. Un projet dont git est actif mais network refusé garde donc tout le travail local, et la zone désactive clairement « Envoyer sur GitHub » et « Récupérer les nouveautés » en disant que le réseau du projet doit être autorisé. Le refus a son propre code, GIT_NETWORK_DENIED (403), pour que la page n'ait pas à interpréter une erreur de capacité générique.

Autoriser le réseau depuis l'interface

La fiche du projet ouvert porte une zone Autorisations du projet : les cinq
capacités y sont écrites en mots, et un contrôle n'apparaît que pour celles que
le serveur déclare modifiables. Dans cette version, une seule l'est :
network. « Autoriser le réseau…» ouvre une confirmation qui nomme le projet et dit ce que l'autorisation ouvre (contacter des services externes, notamment envoyer ou récupérer des changements GitHub) ; « Retirer l'autorisation » en ouvre une autre, qui dit que le travail Git local reste disponible et qu'une opération déjà en cours n'est pas interrompue. La zone Git est relue dans la foulée, donc ses deux boutons distants suivent le changement immédiatement.

Rien de tout cela n'est décidé par la page. L'action est
project.capability.set { name, allowed, confirm }, sur
POST /api/newpi.project : elle ne déclare ni identifiant, ni chemin, ni projet,
donc elle porte toujours sur le projet ouvert, et elle exige
confirm: true. Le nom reçu est comparé à MODIFIABLE_CAPABILITIES côté
modèle (['network']) ; un nom connu mais gardé par le serveur — git par
exemple — est refusé en PROJECT_CAPABILITY_READONLY (403), et un nom inconnu en PROJECT_UNKNOWN_CAPABILITY. La vérification, le type booléen, la confirmation et la garde d'opération passent avant toute écriture : un confirm absent, false ou d'un autre type est refusé en PROJECT_INVALID_ARGS (400), et un refus laisse projects.json identique octet pour octet. La confirmation n'est donc pas une politesse de l'interface : le Project Model la vérifie lui-même, et une page qui oublierait de la demander ne pourrait pas accorder une capacité. Le démarrage, l'ouverture d'un projet et la reprise après une erreur ne touchent jamais une capacité ; le seul écrivain du modèle est
updateCapability(), appelé par cette action et par rien d'autre.

Deux autres garde-fous vont avec :

- resolve(target) ramène tout chemin à l'intérieur de la racine, par la même
fonction que les autres consommateurs — un .. ne fait pas sortir du périmètre, il est refusé.
- Une opération enregistrée auprès du service bloque un changement de
projet (PROJECT_BUSY, 409) : c'est ce qui empêche une écriture longue
d'atterrir dans le projet que l'utilisateur vient d'ouvrir. force: true
est l'échappatoire, et c'est un paramètre qu'un appelant doit écrire.

Les sessions et le handoff

Une session dont le cwd est dans le projet lui est associée, et la décision
est recopiée vers ctx.workspaceRegistry — au mieux, parce qu'un workspace qui refuse une session est dans son droit et que son refus ne doit pas faire échouer l'association. Une compaction terminée est lue depuis le registre de versions du Context & Cache Manager, qui est le seul objet à savoir quand un handoff a été validé : le projet enregistre ce que le manager rapporte plutôt que d'en déduire une seconde réponse.

Chaque couture est optionnelle (workspaceRegistry, sessions,
contextCache, connection) et atteinte par ctx.get : un déploiement sans
elles obtient quand même un projet, parce que « la fonctionnalité est absente »
doit rester distinguable de « NewPi n'a pas démarré ».

L'endpoint, et ce que le navigateur ne peut pas faire

POST /api/newpi.project, derrière la même authentification que le reste de
/api, expose vingt actions : le projet courant, la liste des récents,
ouvrir un projet connu, en créer un pour un dossier existant, fermer,
retirer un projet de la liste des récents, les sessions, les capacités,
changer la capacité réseau du projet ouvert, lire ou fusionner l'état
d'interface, lire les opérations en cours, demander un dossier au
sélecteur natif de l'hôte, demander à NewPi de redémarrer, et les six
actions Git (statut, diff, commit local, fetch, envoi, mise à jour en avance
rapide). La barrière de paramètres est la même que celle des deux consoles : le
navigateur ne peut pas fixer librement les capacités d'un projet, son
namespace mémoire, son handoff, ni l'état d'un autre projet — ces clés ne sont pas déclarées, donc elles sont refusées avant qu'une action les voie. La seule capacité qu'il peut changer est celle que le serveur ouvre, network, et project.capability.set ne déclare ni id, ni rootPath, ni projectId : elle porte toujours sur le projet ouvert. Une action Git ne déclare ni force, ni refspec, ni nom de distant : ses seuls paramètres sont un message et une liste de fichiers. Le redémarrage et le changement de capacité exigent en plus un confirm: true explicite, vérifié par le serveur : le Project Model refuse un confirm absent, false ou d'un autre type avant d'écrire quoi que ce soit. Le sélecteur de dossier s'exécute sur l'hôte, dans osascript : la page ne reçoit jamais de capacité Tauri.

Le service et l'endpoint sont complets et testés, et la section Projets les
lit désormais : le projet ouvert avec son dossier, ses cinq capacités et sa zone
Autorisations du projet, les projets récents avec leur dernière ouverture et
leur dernière session connue, un bouton qui ouvre le sélecteur de dossier natif,
et un retrait de la liste qui ne supprime rien sur le disque. Changer de projet
enregistre le choix comme dernier projet ; le moteur, lui, garde son dossier de
lancement jusqu'au redémarrage de NewPi, et l'interface le dit au lieu de le
cacher.

La capture ci-dessous vient d'une application isolée en marche, prise par
pnpm verify:projects sur un Chrome sans fenêtre : HOME temporaire,
NEWPI_MEMORY=0, et un registre de deux projets. On y voit le projet ouvert
avec ses cinq capacités (Git autorisé, Réseau refusé par défaut), les
récents avec leurs dates, et le retrait discret qui ne supprime rien.

Section Projets : le projet ouvert, ses cinq capacités, les projets récents et le changement

Les deux captures suivantes viennent de pnpm verify:projects-capability, qui
monte un vrai moteur et une vraie interface autour d'un projet sans dépôt Git
et sans réseau : l'état refusé par défaut, puis l'état autorisé après la
confirmation. Ce sont les deux moments du passage refusé, autorisé, refusé que
la sonde prouve de bout en bout.

Autorisations du projet : le réseau refusé par défaut, avec le bouton « Autoriser le réseau… »

Autorisations du projet : le réseau autorisé, avec le bouton « Retirer l'autorisation »

L'historique Git, dans la fiche du projet ouvert

Le projet ouvert peut être un dépôt Git : la même section Projets ajoute
une zone Historique Git sous ses capacités, sans seconde barre latérale,
sans second registre et sans second endpoint. Tout passe par
ctx.projectModel et par les six actions que son endpoint expose :
project.git.status, project.git.diff, project.git.commit,
project.git.fetch, project.git.push et project.git.pull.

Ce que la zone montre, dans les mots d'une personne : le dépôt est-il détecté,
sur quelle branche, avec quel distant, quels fichiers ont changé (et de combien
de lignes), combien de commits locaux attendent, ce que le distant a en plus, et
la dernière synchronisation que NewPi a lui-même faite. Chaque fichier ouvre son
diff lisible à la demande. Trois boutons suffisent : Enregistrer une étape
(choisir des fichiers, écrire un message, créer un commit local), Envoyer sur
GitHub (pousser les commits locaux vers le distant déjà configuré) et
Récupérer les nouveautés (lire le distant, puis proposer une mise à jour
seulement si elle est en avance rapide).

Rien n'est forcé, et la liste de ce qui est impossible est la garantie :

- la racine doit être exacte. Toute lecture commence par
git rev-parse --show-toplevel ; si la réponse n'est pas exactement le
dossier du projet — parce que le projet vit dans un dépôt plus grand —, rien n'est lu et rien n'est exécuté ;
- les dépôts imbriqués et les sous-modules sont refusés pour cette première
version, avec une phrase qui le dit, plutôt qu'opérés au hasard ;
- le local d'abord, le réseau ensuite. git autorise le statut, le diff et
le commit ; fetch, push et la mise à jour demandent en plus network. Si
le réseau est refusé, la zone reste utile en local et désactive ses deux
boutons distants avec une phrase qui dit quoi autoriser ;
- aucune commande destructive n'existe. Les commandes sont une liste
d'arguments (execFile, jamais un shell), et le lanceur refuse reset,
clean, checkout, rebase, stash, branch, toute mutation de remote et
--force sous toutes ses formes. La seule mise à jour est
git merge --ff-only : elle avance, ou elle échoue sans toucher à la copie
locale. Le push n'a aucune option de force, et un envoi refusé par le
distant reste refusé ;
- aucune écriture sans clic. La lecture tourne avec
GIT_OPTIONAL_LOCKS=0 : même le rafraîchissement de l'index n'est pas écrit ;
- une opération Git tient la garde. commit, fetch, push et pull
s'enregistrent comme opération du Project Model : changer de projet, en
retirer un ou demander un redémarrage est refusé (PROJECT_BUSY) tant qu'elle
court ;
- aucun secret n'est stocké. Git s'authentifie avec ce que la machine a
déjà — agent SSH, trousseau macOS, Git Credential Manager — et le prompt interactif est désactivé : une authentification manquante est une phrase, pas une attente. Les diagnostics sont expurgés avant d'être journalisés ou affichés.

Une divergence, un conflit, un dépôt absent, un index.lock, une fusion en
cours ou une branche détachée s'arrêtent avec une explication et l'action
humaine à faire ; aucun de ces cas n'est résolu automatiquement.

La capture ci-dessous vient de pnpm verify:projects-git, qui construit un
dépôt et un distant bare isolés, monte le vrai moteur avec les deux greffons
du projet, et pilote un Chrome sans fenêtre : l'état est affiché, un diff
s'ouvre, une étape est enregistrée, envoyée au distant, puis une nouveauté du
distant est appliquée en avance rapide.

Zone Historique Git dans la fiche du projet ouvert

La page distante ne reçoit aucun accès à Tauri

L'écran de démarrage est du contenu local et il est le seul à disposer d'une
capacité Tauri, limitée au bus d'évènements. L'interface, chargée
depuis http://127.0.0.1, ne reçoit aucune API Tauri. Cette séparation est
volontaire : cette page reste une page web ordinaire, et la surface
d'attaque côté application native reste vide.

Ce qui a été vérifié

Chaque point ci dessous a été mesuré sur cette machine, pas seulement compilé.

| Point | Preuve |
| Compilation sans avertissement | cargo build et cargo test propres |
| Arrêt du groupe de processus | test Rust shutdown_stops_the_whole_process_group |
| Lecture de la ligne de disponibilité | trois tests, dont le suffixe réseau local |
| Fenêtre native | application enregistrée type="Foreground" avec ses processus WebKit |
| Interface réellement rendue | une sonde injectée dans la fenêtre a rapporté le titre de la page et le texte de l'écran d'accueil, sur le binaire comme sur le bundle |
| Échange de jeton | redirection 303 puis cookie signé, et 401 sans cookie |
| Cache du piège inter site | l'interface reste authentifiée au lancement comme à la relance, et quatre connexions s'établissent vers le runtime, signe que les ressources du SPA sont chargées |
| Arrêt propre, chemin Tauri | quit sur le bundle, puis aucun processus restant et port libéré |
| Arrêt propre, chemin signal | SIGTERM sur l'application, puis aucun processus restant |
| Commandes documentées | pnpm dev et pnpm build exécutées, .app et .dmg produits |
| Sidecar PocketBase en boucle locale | pnpm verify:running : un seul socket, 127.0.0.1, jamais 0.0.0.0 |
| Migration appliquée pour de vrai | pnpm test:pocketbase : colonnes et deux index lus dans data.db après démarrage du binaire officiel |
| Création, rappel, suppression | les deux suites : aller-retour complet sur PocketBase réel et sur un faux en mémoire |
| Persistance après redémarrage | arrêt puis relance du sidecar sur le même pb_data, la mémoire est toujours là |
| Cloisonnement entre projets | alpha ne voit pas et ne supprime pas la mémoire de beta, en test unitaire comme sur la base réelle |
| Aucun project_id dans les outils | un test lit les schémas des trois outils et refuse ce champ ; un appel qui le passerait est rejeté par le schéma |
| Schémas d'outils conformes | les définitions passent les assertions du registre du harness lui-même, importées par les tests |
| Patch de lancement accepté | le YAML produit par Rust est relu par js-yaml (le lecteur du harness) et le harness démarre avec |
| Mémoire non bloquante au démarrage | un sidecar injoignable est signalé et l'interface s'ouvre quand même |
| Icône de l'application | assets/logo.svg → icon-source.png 1024 px transparent → .icns ; codesign -v reste valide |
| Mark de la barre latérale | mesuré : tracé du moteur en display:none, /newpi/whale.svg servi en image/svg+xml et chargé en 28×28, rangée de 28 px, aucune erreur |
| Taille du mark choisie sur mesure | les deux variantes rendues côte à côte à 24 et 28 px ; la silhouette simplifiée à 28 px est la seule qui reste lisible |
| Sections injectées dans le bon siège | mesuré sur l'application en marche : cette version du moteur déclare sidebar.panellist mais ne la rend pas tant que personne ne s'y enregistre ; les deux entrées atterrissent donc dans sidebar.footer.action, et le repli est couvert par un test |
| Bouton principal lisible | mesuré dans le thème sombre : --dsw-alias-brand-primary y vaut #f9fafb, donc du texte blanc sur ce fond disparaissait ; le bouton prend maintenant la paire du thème, remplissage plus label-primary-foreground |
| Interface renommée | mesuré dans un navigateur sur l'application réelle : document.title = NewPi (le client le réécrivait, l'accesseur est verrouillé), tracé du wordmark en display: none (0×0), libellé NewPi rendu (46×15), aucune erreur de page, « HARNESS » absent du DOM |
| Sections Memory et Backup | pnpm verify:console sur une application en marche : deux entrées dans la barre latérale, le panneau Memory lit le projet (newpi-console-demo, 3 souvenirs, note 1 · decision 1 · bugfix 1 · lesson 0), le panneau Backup affiche l'écran de confirmation — capture dans console-memory.png et console-backup.png |
| Section Projets | pnpm verify:projects sur une application isolée (HOME temporaire, NEWPI_MEMORY=0) : l'entrée « Projets » est dans la barre latérale, le panneau lit le projet ouvert et son dossier, les cinq capacités sont en mots (Git autorisé, Réseau refusé), les récents affichent leur dernière ouverture et leur dernière session connue, la confirmation du changement nomme le projet actuel et le projet cible, et aucune des chaînes internes (memoryNamespace, workspaceId, handoff, detached) n'est sur la page — capture dans console-projects.png |
| Dernier projet repris au lancement | deux lancements de la même application isolée : projects.json porte currentId, et la ligne [newpi/memory] projet=… du second lancement nomme le projet choisi, sans NEWPI_WORKSPACE. La racine par défaut serait le HOME temporaire, donc la valeur vient bien du registre |
| Projet non supprimé par un retrait | tests/project-model.test.mjs : après project.forget, le dossier et le projects.json des autres projets sont intacts (stat), le projet ouvert garde ses sessions et son namespace |
| Redémarrage refusé sans bundle | tests/project-model.test.mjs : project.restart répond PROJECT_RESTART_UNAVAILABLE (409) quand NewPi n'est pas une application, et PROJECT_INVALID_ARGS sans confirm: true ; rien n'est demandé à l'hôte |
| Restauration pilotée par l'interface | pnpm verify:console --restore-round-trip true : l'archive est supprimée, puis choisie par le de la fenêtre — le fichier lui est remis par l'API du navigateur, comme le ferait un clic — l'écran de métadonnées s'affiche, le bouton « Restaurer et remplacer la mémoire locale » est cliqué, et le souvenir supprimé revient avec son identifiant |
| Panneau d'enregistrement | pnpm verify:console --probe-save-panel true : la demande reste en suspens pendant que le panneau est ouvert (c'est ce qu'un panneau modal montre à la page), le processus du sélecteur est mis fin, et la réponse est {cancelled: true} sans erreur. Deux exécutions ont été jusqu'au bout : le panneau a été accepté et a produit une archive au manifeste valide |
| Script du panneau compilé | test unitaire : osacompile compile le script généré, sans afficher de panneau — c'est ce test qui a trouvé l'instruction collée qui cassait le bouton |
| L'interface ne peut pas nommer un projet | pnpm verify:console : un project_id envoyé par la page est refusé (400, unknown parameter) ; l'action inconnue répond 404 |
| L'interface ne reçoit aucun secret | pnpm verify:console : 404 619 octets de DOM sans mot de passe, sans identifiant, sans nom de variable d'environnement et sans adresse du sidecar |
| L'endpoint n'est pas ouvert | pnpm verify:console : le même appel sans le cookie de session répond 401, et l'interface elle-même répond 401 |
| Sauvegarde, suppression, restauration | pnpm test:backup sur le vrai binaire : deux projets isolés, une sauvegarde, une suppression, une restauration — la mémoire supprimée revient avec son identifiant, l'autre projet est intact, sqlite3 le confirme dans data.db |
| Format d'archive vérifié à la main | pnpm test:backup : l'archive contient exactement manifest.json, pb_data/data.db, pb_data/auxiliary.db, pb_data/types.d.ts, et le manifeste imprimé porte les deux versions, la date et les empreintes |
| Archive corrompue refusée | test unitaire : une archive dont data.db a été réécrit est refusée sur l'empreinte, et la base est remise dans son état précédent depuis la sauvegarde de sécurité |
| Écritures bloquées pendant une restauration | test unitaire : un remember lancé pendant la restauration reste en attente, n'apparaît pas dans la base restaurée, et aboutit après |
| Tour réel avec le modèle | node scripts/acceptance-memory-e2e.mjs : une session DeepSeek réelle appelle remember, puis recall, puis forget — voir le détail ci dessous |
| La racine est la seule atteignable | test unitaire, sur un vrai dossier temporaire : .., un chemin absolu, ~, un lien symbolique vers /etc et un lien vers un dossier voisin sont refusés en 403, et rien n'est écrit dehors |
| Un lien symbolique interne reste un lien | test unitaire : écrire à travers alias/link.txt modifie la cible et laisse le lien en place |
| Ni périphérique ni FIFO | test unitaire : un FIFO créé dans le workspace est refusé (FILE_NOT_REGULAR) au lieu de bloquer la lecture, et n'est pas listé |
| Les dossiers lourds ne sont pas listés | test unitaire : .git, node_modules, target, dist, .cache et .DS_Store sont absents de l'arbre, .env y est |
| Binaire et trop gros | test unitaire : un PNG et un fichier de 3 Mio reviennent en kind: binary et kind: too-large, sans contenu — et le fichier à la limite exacte reste lisible |
| L'écriture ne tronque jamais | test unitaire : soixante lectures concurrentes pendant l'écriture de 400 Kio ne voient que l'ancienne version ou la nouvelle, jamais un préfixe, et aucun temporaire ne survit |
| Conflit, et remplacement explicite | test unitaire : une écriture avec un jeton périmé est refusée en 409 sans toucher au fichier, force: true remplace, et une écriture sans jeton ne peut pas écraser un fichier jamais lu |
| La modification de l'agent est vue | test unitaire : stat change de jeton après une écriture faite directement sur le disque, et disparaît quand le fichier est supprimé |
| La mesure disque égale du | sur src-tauri/target : du -sk lit 3 913 084 928 octets, la mesure du greffon lit 3 913 084 928 octets — les liens durs comptés une fois, les liens symboliques jamais suivis |
| Nettoyage sûr dans un répertoire isolé | un faux dossier personnel construit pour le test : 12 Mio de build jetable et 4 Mio de cache npm partent, la session, data.db et la sauvegarde ZIP restent, et le cache vidé garde sa place |
| Sessions, mémoire et sauvegardes refusées | pnpm storage --clean --confirm sur dsh-sessions, newpi-pb-data et newpi-backups répond STORAGE_GUARDED_TARGET et sort en 2 — le refus vient du catalogue, avant toute résolution de chemin, et les fichiers sont toujours là après |
| Confirmation non recopiée refusée | --clean newpi-target sans --confirm affiche la cible et la taille allouée puis refuse ; le serveur exige la recopie de l'identifiant et une taille encore conforme, sinon 409 |
| La section Storage, sur un vrai moteur | un vrai dsh web et un vrai PocketBase dans un monde isolé — 16 sessions réelles et une copie de pb_data — avec le patch de lancement produit par le générateur Rust : 18/18, dont storage.status à 39 cibles, le nettoyage d'un build à 3 002 368 octets récupérés, memory.status qui relit les 5 souvenirs du projet cyril, et les 16 sessions intactes empreinte par empreinte |
| L'instance en cours n'est pas touchée | NewPi et son sidecar tournaient pendant tout l'audit et n'ont pas été redémarrés ; les 5 souvenirs sont présents et inchangés après coup. L'empreinte du fichier data.db a en revanche changé — PocketBase écrit son propre compte rendu sur la connexion vivante, authentification superutilisateur comprise — ce qui est mesuré et dit plutôt que passé sous silence |
| Project Model : registre et garde-fous | tests/project-model.test.mjs : le projet de lancement est ouvert, lié à son workspace et rendu courant ; create, open et close déplacent un seul projet courant en gardant les récents ; une opération en vol refuse un changement (PROJECT_BUSY, 409) et force est le seul passage ; resolve refuse un chemin hors racine ; un dossier déplacé met la racine à jour sans créer un second projet |
| Project Model : endpoint et coutures optionnelles | le même test : /api/newpi.project est enregistré et refuse un paramètre non déclaré ; un déploiement sans workspace registry ni session store obtient quand même un projet ; un projects.json corrompu coûte les récents et non le lancement |
| Git : la racine exacte, et ce qui est refusé | tests/project-git.test.mjs, sur de vrais dépôts : un projet qui vit dans un dépôt plus grand est refusé (GIT_ROOT_MISMATCH), un dossier sans dépôt est répondu sans erreur, un dépôt imbriqué (GIT_NESTED_REPOSITORY) et un sous-module (GIT_SUBMODULE_UNSUPPORTED) sont refusés avec une phrase, et un chemin hors racine ne quitte jamais le périmètre |
| Git : statut, diff, commit des fichiers choisis | le même test : git status --porcelain=v2 est parses en branche, distant, compteurs et fichiers avec leurs +n/−m ; un diff d'index et un diff de fichier non suivi sont lisibles ; un commit ne contient que les fichiers choisis, garde les autres changements indexés hors du commit, et refuse une liste ou un message vide avant que Git ne voie quoi que ce soit |
| Git : push normal autorisé, push forcé impossible | le même test : un commit local atteint le distant bare ; une divergence fait refuser l'envoi (GIT_PUSH_REJECTED, 409) et laisse le distant intact ; assertSafeGitArgs refuse reset, clean, checkout, rebase, branch -D, toute mutation de remote, push --force/-f/--force-with-lease/--delete et merge sans --ff-only, et un audit enregistre chaque argv réellement exécuté |
| Git : fetch, avance rapide, divergence et conflit | le même test : fetch lit le distant sans toucher la copie locale ; pull applique une avance rapide sans commit de fusion ; une divergence est refusée et HEAD comme le fichier restent identiques ; un conflit et un MERGE_HEAD refusent commit, fetch, push et pull ; une copie sale bloque la mise à jour (GIT_WORKTREE_DIRTY) |
| Git : la garde tient pendant une opération | le même test, avec un lanceur Git réellement lent : pendant model.gitStatus(), open, forget et restart répondent PROJECT_BUSY (409) et le projet ne bouge pas ; restart consulte donc la garde, ce qu'il ne faisait pas |
| Git : l'endpoint et la capacité | le même test : les six actions passent par /api/newpi.project, et toute clé non déclarée (force, remote, refspec, branch, hard) est refusée (PROJECT_INVALID_ARGS, 400) avant d'atteindre une action |
| Git : la double autorisation | le même test : avec git refusé et network accordé, les six actions répondent 403 PROJECT_CAPABILITY_DENIED (le réseau n'ouvre rien en local) ; avec git seul (le défaut), statut, diff et commit passent, et fetch, push et pull répondent 403 GIT_NETWORK_DENIED sans que Git soit lancé (le distant n'a pas bougé) ; avec git + network, fetch atteint Git et échoue pour sa propre raison (GIT_NO_REMOTE) ; le statut porte networkAllowed, et la zone désactive ses deux boutons distants en l'expliquant |
| Zone Git : preuve dans l'interface | pnpm verify:projects-git : dépôt et distant bare isolés, vrai dsh web monté avec les deux greffons du projet, Chrome sans fenêtre — la zone « Historique Git » s'affiche dans la fiche du projet ouvert avec la branche et le distant, un diff s'ouvre, « Enregistrer une étape » crée un vrai commit local (vérifié par git log), « Envoyer sur GitHub » l'envoie au distant (vérifié dans le bare), puis « Récupérer les nouveautés » propose et applique une avance rapide (vérifié : 3 commits, pas de fusion). Le projet de test porte network: true sur son enregistrement, puisque git seul ne suffit plus au distant — capture dans console-projects-git.png |
| Capacité réseau : refusée, autorisée, refusée | pnpm verify:projects-capability : vrai dsh web monté avec les deux greffons du projet dans un monde isolé, sans aucun dépôt Git et sans aucun réseau — la zone « Autorisations du projet » affiche Réseau refusé et le bouton « Autoriser le réseau… » ; la confirmation nomme le projet, son dossier, GitHub et la promesse de ne pas interrompre une opération en cours ; « Annuler » ne fait aucune écriture ; la confirmation acceptée écrit network: true dans projects.json et le fait que la zone Git lit (networkAllowed) passe à true ; « Retirer l'autorisation » confirmée ramène l'enregistrement et le fait à false. L'interface ne joint confirm: true que depuis le bouton de confirmation, et le modèle le revérifie. Captures dans console-projects-network-refused.png et console-projects-network-allowed.png |
| Capacités : une seule porte, et rien d'écrit sans elle | tests/project-model.test.mjs : MODIFIABLE_CAPABILITIES vaut ['network'] ; git est refusé en PROJECT_CAPABILITY_READONLY (403), un nom inconnu en PROJECT_UNKNOWN_CAPABILITY, un non booléen et toute clé non déclarée (id, rootPath, capabilities) en PROJECT_INVALID_ARGS ; confirm absent, false, 'yes', 1 ou null est refusé en PROJECT_INVALID_ARGS (400) sans écrire, et confirm: true est le seul chemin qui réussit ; chaque refus laisse projects.json identique octet pour octet (vérifié dans la boucle) ; une opération critique refuse en PROJECT_BUSY sans écrire ; sans projet ouvert, la réponse est PROJECT_NONE_OPEN (409) et aucun projet n'est créé ; ouvrir ou démarrer un projet ne change jamais une capacité, y compris un network: true déjà enregistré |
| Capacité : le distant Git suit le changement | tests/project-git.test.mjs : project.git.fetch répond 403 GIT_NETWORK_DENIED avant, project.capability.set {name:'network',allowed:true} fait passer networkAllowed à true, le fetch suivant atteint réellement Git sur le distant bare isolé, le retrait ramène networkAllowed à false, les trois actions distantes sont refusées de nouveau, et un commit local continue de fonctionner pendant tout ce temps |

Le tour réel, en détail

Le 12 septembre 2026, sur cette machine, une session DeepSeek réelle a exécuté
le cycle complet. Le script pilote le harness par son propre RPC, puis lit la
preuve à trois endroits indépendants : le journal de session durable, la base
PocketBase, et les résultats d'outils que le modèle a reçus.

projet de test newpi-acceptance-e2e (isolé, épinglé dans .newpi/cordis.yml)
marqueur acceptance-1789241773374
session session-5390cacb-e28f-495d-87d1-e6dd86c54d38

le modèle a émis remember, puis recall
tool/call remember seq=17 content="acceptance-… : le projet de test …"
tool/call recall seq=22 query="acceptance-…"
aucun appel ne portait de project_id — le schéma l'interdit ligne en base 1 seule : dzlyz8cv063euhm · kind=note · sous newpi-acceptance-e2e résultat du recall "[note] dzlyz8cv063euhm · 2026-09-12 …\nacceptance-… : …"
méta du recall {"count":1} (la projection déclarée par l'outil)
tool/call forget seq=35 id=dzlyz8cv063euhm
résultat du forget "Forgot dzlyz8cv063euhm."
base après 0 ligne dans le projet, 0 ligne dans toute la base

Trois choses sont prouvées par cette sortie, et aucune ne l'était par les tests
précédents :

1. Les trois outils sont réellement offerts au modèle. Ce n'est pas une
déduction sur le registre : le modèle les a appelés, et ses appels sont
enregistrés dans le journal de session.
2. Le cloisonnement tient côté modèle. Aucun appel ne portait de portée ;
l'identifiant vient du cordis.yml du projet, et la base ne contenait qu'une
ligne, sous ce seul projet.
3. L'aller-retour est réel. Le recall a renvoyé la mémoire que le
remember venait d'écrire, avec son identifiant et son horodatage, et
forget l'a supprimée — la base est revenue à zéro ligne, vérifié par une requête SQLite directe après l'arrêt de l'application.

Le script nettoie derrière lui : la mémoire est supprimée par l'agent avec
forget, et la session de test comme l'espace de travail temporaire sont
retirés. La preuve brute est écrite dans
$TMPDIR/newpi-acceptance-evidence.json.

Arborescence

newpi/
├── README.md ce document : de quoi décider si NewPi vous convient
├── CONTRIBUTING.md mise en route, portail de tests, invariants
├── SECURITY.md le canal privé de signalement des failles
├── AGENTS.md la carte du dépôt, pour un agent comme pour une personne
├── LICENSE MIT
├── package.json scripts pnpm et CLI Tauri
├── ui/index.html écran de démarrage local
├── pb_migrations/ migrations PocketBase versionnées
├── plugins/
│ ├── pocketbase-memory/ service mémoire : connexion, création, recherche, suppression
│ ├── memory-tools/ provider : remember, recall, forget
│ ├── memory-console/ les sections Memory et Backup, le pont, le format d'archive
│ ├── storage-console/ la section Storage : catalogue, mesure, nettoyage sûr
│ │ ├── catalog.js la politique : rôle, classe et budget de chaque cible
│ │ ├── scan.js mesure bornée, liens non suivis, liens durs comptés une fois
│ │ ├── cleanup.js les trois verrous, et le refus des cibles protégées
│ │ └── ui.js la section injectée
│ ├── model-router/ la politique de routage : mode, rôles, repli, bail
│ │ ├── plan.js le contrat de configuration, validé des deux côtés
│ │ ├── routing.js les décisions pures : repli, capacités, nettoyage
│ │ └── index.js le service ctx.modelRouter, les baux, le journal
│ ├── context-cache-manager/ les couches, les versions, la télémétrie du cache
│ │ ├── context.js le modèle de couches et le registre des versions
│ │ ├── cache.js l'arithmétique du cache, la température, le nettoyage
│ │ └── index.js le service ctx.contextCache et ses trois coutures
│ ├── project-model/ le projet courant, son registre, ses garde-fous et Git
│ │ ├── model.js capacités, chemins, garde d'opération, handoff
│ │ ├── store.js projects.json, écriture atomique, lecture tolérante
│ │ ├── platform.js le sélecteur de dossier natif et la relance propre
│ │ ├── git.js la couture Git : racine exacte, argv structuré, six verbes
│ │ └── index.js le service ctx.projectModel et son endpoint
│ ├── projects-console/ la section Projets, qui lit le modèle ci-dessus
│ │ ├── ui.js la section injectée : barre latérale, panneau, choix, autorisations, zone Git
│ │ └── index.js le service ctx.projectsConsole, sans endpoint
│ ├── terminal-console/ la console : une commande à la fois, sortie en flux NDJSON
│ ├── newpi-brand/ le nom du produit dans le titre de l'interface
│ └── session-queue-guard/ un message envoyé pendant un tour attend la fin de ce tour
├── docs/
│ ├── features.md l'inventaire des fonctionnalités, en table
│ └── storage-audit.md l'audit disque : inventaire, causes, rétention
├── assets/
│ ├── logo.svg le logo de l'application (source de l'icône)
│ ├── whale.svg le mark de la barre latérale
│ └── icon-source.png produit par pnpm icon, ignoré par git
├── scripts/
│ ├── make-icon.mjs rastérise logo.svg en icône source
│ ├── pocketbase-pin.mjs version et empreintes du sidecar
│ ├── fetch-pocketbase.mjs télécharge et vérifie l'archive épinglée
│ ├── link-harness-modules.mjs copie les modules du harness pour les tests
│ ├── verify-running-newpi.mjs contrôle d'acceptation sur une instance vivante
│ ├── probe-console-ui.mjs pilote un vrai navigateur sur les deux sections
│ ├── probe-projects-ui.mjs pilote un vrai navigateur sur la section Projets
│ ├── probe-projects-git-ui.mjs monte un dépôt et un distant bare, et prouve la zone Git
│ ├── probe-projects-capability-ui.mjs prouve le réseau refusé, autorisé, refusé, sans dépôt ni réseau
│ ├── probe-scope-ui.mjs prouve la portée du lancement : projet ouvert, changement, aucun projet
│ └── storage.mjs la commande pnpm storage : tableau, aperçu, nettoyage
├── tests/
│ ├── memory.test.mjs comportement des plugins mémoire
│ ├── memory-console.test.mjs console et sauvegarde, sur le faux PocketBase
│ ├── storage-console.test.mjs catalogue, mesure, nettoyage, refus, section injectée
│ ├── model-router.test.mjs modes, rôles, baux, repli, capacités, refus, journal
│ ├── context-cache.test.mjs couches, versions, handoffs, cache, absences, API
│ ├── project-model.test.mjs registre, garde, capacités, sessions, endpoint
│ ├── project-git.test.mjs Git sur de vrais dépôts et un distant bare
│ ├── projects-console.test.mjs section Projets : injection, DOM, zone Git, autorisations
│ ├── session-queue-guard.test.mjs la file d'attente des messages, et son unique couture
│ ├── fake-pocketbase.mjs faux PocketBase, filtres et sauvegardes compris
│ ├── pocketbase-live.mjs preuve contre le vrai binaire
│ └── backup-live.mjs sauvegarde, suppression, restauration, vérification
├── vendor/pocketbase/ archive officielle épinglée
├── assets/icon-source.png icône 1024 px, produite par le script
└── src-tauri/
├── Cargo.toml
├── build.rs
├── tauri.conf.json fenêtre, bundle, identifiant
├── capabilities/ permissions, contenu local uniquement
├── icons/ icônes dérivées par la CLI Tauri
└── src/
├── main.rs cycle de vie de l'application
├── runtime.rs résolution, lancement et arrêt du runtime et du sidecar
├── memory.rs disposition sur disque, portée du projet, patch de lancement
├── models.rs lecture et validation du plan de routage
├── pocketbase.rs provisionnement, démarrage et vérification du sidecar
├── patch.rs génération du patch de lancement
├── assets.rs plugins et migration embarqués dans le binaire
└── process.rs groupes de processus et arrêt propre

Où NewPi trouve le sidecar mémoire

L'archive PocketBase est épinglée dans scripts/pocketbase-pin.mjs (version,
nom d'archive, empreinte SHA-256, taille) et versionnée dans
vendor/pocketbase/. pnpm install, pnpm dev et pnpm build vérifient sa
présence ; node scripts/fetch-pocketbase.mjs la télécharge si elle manque, en
la confrontant au checksums.txt publié par la release puis à l'empreinte
épinglée. Au démarrage, NewPi la reprend depuis le binaire, revérifie les deux
empreintes, extrait l'exécutable dans son répertoire d'état et note la version
à côté — une extraction réussie n'est donc jamais refaite, et une archive modifiée arrête le lancement au lieu de s'exécuter.

Pour changer de version : node scripts/fetch-pocketbase.mjs --version vX.Y.Z
affiche les valeurs à reporter dans l'épinglage, puis relancez le script sans
option. Le numéro n'est jamais modifié à la main dans deux fichiers à la fois.

Où NewPi trouve le runtime

La résolution essaie dans cet ordre et s'arrête à la première réussite :

1. un runtime embarqué dans l'application, sous
Contents/Resources/runtime/node/bin/node et
Contents/Resources/runtime/dsh/lib/bin.js ;
2. la variable NEWPI_NODE pour l'interpréteur et NEWPI_DSH_ENTRY pour le
point d'entrée ;
3. NEWPI_DSH_BIN, puis la commande dsh trouvée sur le PATH.

L'étape 1 est déjà implémentée mais aucun runtime n'est encore embarqué : voir
la section suivante. La recherche sur le PATH complète le PATH reçu avec
~/.local/bin, ~/bin, ~/.cargo/bin, /usr/local/bin et
/opt/homebrew/bin, parce qu'une application ouverte depuis le Finder
n'hérite pas de l'environnement du terminal.

Variables d'environnement

| Variable | Effet |
| NEWPI_NODE | Interpréteur Node à utiliser |
| NEWPI_DSH_ENTRY | Point d'entrée du harness, par exemple .../dsh/lib/bin.js |
| NEWPI_DSH_BIN | Commande dsh à résoudre, au lieu de la chercher sur le PATH |
| NEWPI_WORKSPACE | Racine de travail du harness. Par défaut le dernier projet choisi, sinon le dossier personnel |
| NEWPI_MEMORY | 0, false, no ou off désactivent la mémoire pour ce lancement |
| NEWPI_POCKETBASE | Chemin d'un binaire PocketBase à utiliser au lieu de l'archive épinglée |
| DSH_HOME | Lue par le harness lui même, comme en ligne de commande |

NewPi pose aussi quatre variables pour les plugins mémoire, lues une fois au
chargement : DSH_MEMORY_URL, DSH_MEMORY_IDENTITY, DSH_MEMORY_PASSWORD et
DSH_MEMORY_PROJECT_ID. Elles sont renseignées par NewPi et n'ont pas à être
définies à la main ; un lancement manuel du harness peut les fournir pour
joindre un PocketBase déjà démarré.

La console, elle, ne lit aucune variable : ses quatre réglages — backupDir, snapshotDir, dataDir et les deux versions — voyagent dans la ligne du patch de lancement, parce qu'aucun n'est un secret. Le mot de passe et l'URL du sidecar continuent de passer par l'environnement, où l'interface ne peut pas les lire.

Limites connues

- La racine de travail suit le dernier projet choisi : NEWPI_WORKSPACE
quand il est défini, sinon le projet courant de projects.json, sinon le
dossier personnel — qui n'est alors pas un projet et ne porte aucune portée mémoire. Le choix fait dans la section Projets est donc repris au lancement suivant ; en revanche, le moteur garde son dossier tant qu'il tourne, parce qu'un processus a un seul dossier de travail et une seule base mémoire. C'est pourquoi l'interface annonce le redémarrage au lieu de le laisser deviner.
- Le bouton « Redémarrer NewPi » n'existe que lorsque NewPi tourne depuis un
.app. Sous pnpm dev (un binaire nu), la section le dit et demande de
quitter puis rouvrir l'application à la main. La relance utilise osascript
et l'identifiant de bundle : macOS peut demander une autorisation
d'automatisation la première fois.
- La section Projets n'ouvre pas la dernière session d'un projet dans le
moteur. Le registre garde la date de la dernière session connue et l'affiche,
mais une section injectée n'a aucune couture vers la navigation du client ;
le moteur montre donc son propre écran, avec les sessions du projet dans la
barre latérale. Un vrai plugin client serait nécessaire pour aller plus loin,
et ce n'est pas ce lot.
- La zone Historique Git est volontairement étroite. Elle ne crée pas de
dépôt, ne configure ni ne modifie aucun remote, ne gère ni les dépôts
imbriqués ni les sous-modules, et ne fait ni fusion, ni rebase, ni changement
de branche, ni remisage : elle lit l'état, montre un diff, crée un commit
local des fichiers choisis, fetch, push sans force et merge --ff-only.
« GitHub » désigne le distant déjà configuré dans le dépôt ; l'authentification
est celle de Git sur la machine, et NewPi ne stocke aucun identifiant. La
détection d'un dépôt imbriqué est bornée (profondeur 6, 20 000 entrées,
dossiers de dépendances ignorés) : c'est une limite de la vérification, pas
une promesse.
- Une seule capacité est réglable depuis l'interface. La zone
« Autorisations du projet » accorde et retire network, sur le projet ouvert
seulement, après une confirmation explicite, et la zone Git suit le
changement. Les quatre autres capacités (readWorkspace, writeWorkspace,
terminal, git) restent décidées par le serveur : la liste des capacités
modifiables est la sienne (MODIFIABLE_CAPABILITIES), et l'ouvrir à d'autres
noms demandera une décision, pas un bouton. Un projet livré tel quel ne voit
donc que la moitié locale de la zone Git tant que personne n'autorise le
réseau.
- Il n'y a plus d'éditeur de fichiers. Les fichiers du projet se consultent
dans l'arbre de la colonne de droite du moteur et s'ouvrent dans son aperçu,
en lecture seule. La racine, la lecture, l'écriture et les conflits décrits
plus haut ont été retirés avec lui ; un futur éditeur repartira de ce
document et des capacités du Project Model plutôt que du code retiré.
- Une seule fenêtre et un seul runtime à la fois. Rien n'empêche de lancer
deux copies de NewPi, chacune avec son propre port.
- Le paquet n'est pas signé ni notarié, donc Gatekeeper le refusera sur une
autre machine que celle qui l'a produit.
- Le catalogue de la section Storage est une table écrite à la main. Une
nouvelle cache — un outil de plus, un gestionnaire de paquets de plus — est invisible tant que personne ne l'y ajoute ; l'audit le dit et ne prétend pas le contraire. Les entrées génériques du projet (target, node_modules) se découvrent seules, puisque leurs chemins viennent de l'espace de travail.
- La mesure est bornée : une cible énorme peut être rapportée
mesure partielle plutôt que de faire attendre le panneau indéfiniment.
Le chiffre affiché est alors un minorant, jamais une estimation.
- NEWPI_MEMORY=0 ne monte que les greffons sans base de données — le nom du produit, le Project Model et sa section Projets (Git compris), la section Storage et le gestionnaire de contexte et de cache. Les sections Memory et Backup disparaissent avec le sidecar, ce qui est le sens de la variable.
- La rotation des journaux et la conservation des sessions sont des règles
affichées, pas des programmes. Aucun composant de NewPi ne supprime une
session ou un journal ; la politique existe pour être lue à côté du chiffre.

Limites de la mémoire en v1

Ce qui est volontairement absent, et pourquoi :

- Recherche textuelle seulement. recall fait un LIKE insensible à la
casse sur le contenu, du plus récent au plus ancien, plafonné à 8 résultats
par défaut et 50 au maximum. Pas de vecteurs, pas d'embedding, pas de rappel
sémantique, aucun appel externe : une requête qui ne partage pas un mot avec
la mémoire ne la trouvera pas. C'est le compromis assumé de cette version.
- Pas de mise à jour en place. Une mémoire se corrige en écrivant la
nouvelle puis en supprimant l'ancienne avec forget. Rien ne modifie une
ligne existante, ce qui garde created_at honnête.
- Pas de déduplication. Deux remember identiques créent deux lignes. Le
modèle est censé rappeler avant d'écrire ; rien ne l'y force.
- Le cloisonnement est par chaîne, pas par utilisateur. Quiconque peut
écrire dans le cordis.yml d'un projet peut lire la mémoire de ce projet.
C'est la bonne maille pour une machine personnelle, pas pour un service
multi-utilisateurs.
- Une seule machine. Le pb_data n'est ni synchronisé ni sauvegardé par
un tiers. Les sauvegardes NewPi sont des fichiers locaux : les copier ailleurs
est à la charge de l'utilisateur. Supprimer le dossier efface la mémoire de
tous les projets.
- La portée dérivée peut collisionner. Deux projets de même nom de dossier
partagent la même mémoire tant que memory.project_id n'est pas épinglé.
- Le sidecar n'est pas surveillé. S'il meurt en cours de session, les
appels mémoire échouent avec un message de transport et ne redémarrent qu'au
prochain lancement de NewPi.
- Pas d'outil multi-mémoire. remember n'écrit qu'une mémoire à la fois,
et il n'existe pas d'outil pour lister les projets ni pour purger une portée.
- Outils et portée globaux au processus. Les trois outils sont enregistrés
une fois pour le harness, avec la portée du projet lancé. Deux projets dans
la même instance de NewPi n'est pas un cas supporté : un lancement, un
projet. C'est aussi la portée de la console : elle montre le projet du
lancement, et changer de projet veut dire relancer NewPi dans un autre
dossier.
- La console ne crée pas de souvenir. Elle lit, cherche, ouvre et supprime;
écrire une mémoire reste le travail de l'agent, par l'outil remember. Une
saisie manuelle serait une seconde source d'écriture à tenir cohérente avec la
première.
- Une sauvegarde porte sur toute la base, pas sur un projet. Le fichier
contient les souvenirs de tous les projets de la machine, et le restaurer les
remplace tous. C'est ce que dit l'avertissement affiché avant confirmation ;
une sauvegarde par projet demanderait un format différent, pas un filtre sur
celui-ci.
- Le manifeste d'une archive dit combien de souvenirs elle contient, par
base. L'interface affiche le total et la part du projet courant ; elle
n'énumère jamais le contenu d'un autre projet.
- Le panneau d'enregistrement n'appartient pas à NewPi. Il est demandé par
osascript — le mécanisme du moteur lui-même pour un sélecteur natif côté hôte — donc la fenêtre appartient à ce processus. Le panneau de choix, lui, est celui de la fenêtre. Aucun des deux n'est cliquable par un agent : ce que la vérification prouve, c'est que le panneau de choix est câblé et que l'archive qu'il rend restaure réellement la base, et que le panneau d'enregistrement s'affiche, attend, et se ferme proprement.
- Un panneau qui n'a pas compilé ne s'ouvrait pas. Mesuré : le script
AppleScript était assemblé sans retour à la ligne entre ses deux instructions,
et macOS le refusait — le bouton « Créer une sauvegarde » échouait donc à l'ouverture du panneau, pas à l'écriture. Le script est maintenant une fonction pure, et un test le fait compiler par osacompile, sans jamais afficher de panneau.
- Un dossier absent faisait échouer la sauvegarde, de trois façons. Mesuré :
AppleScript ne convertit pas un POSIX file vers un dossier inexistant, donc
le panneau refusait de s'ouvrir ; pick: false écrivait dans le vide et
remontait un ENOENT brut ; et la sauvegarde de sécurité d'une restauration
passait par le même chemin. Le dossier du panneau est créé avant la demande, et
le dossier de destination est créé par la seule fonction qui écrit une archive.
Un test supprime le dossier et vérifie les deux chemins.
- La restauration ne répare pas une archive étrangère. Une archive dont le
superutilisateur n'est pas celui de cette machine est restaurée, mais la
vérification échoue, et l'état précédent est remis en place : NewPi le dit et
n'insiste pas.

Décision à trancher : embarquer le runtime ou pas

Aujourd'hui NewPi suppose que Node et dsh sont installés sur la machine.
C'est parfait pour développer, et cela réutilise la configuration du moteur
existante de l'utilisateur, donc ses modèles et ses sessions.

Pour livrer un vrai logiciel autonome, il faut embarquer un Node et un paquet
dsh dans Contents/Resources/runtime. Le code de résolution gère déjà ce
chemin, il ne reste donc qu'à ajouter les fichiers au bundle.

Le compromis mérite une décision explicite :

- embarquer rend l'application autonome, au prix d'un paquet d'environ 300 Mo
et de binaires natifs supplémentaires à signer et à notarier ;
- ne pas embarquer garde un paquet léger, au prix d'une installation
préalable de Node et du harness.

Mesure du prototype actuel, sidecar mémoire compris : NewPi.app pèse 15 Mo et
le .dmg 13 Mo, contre 3 Mo et 1,3 Mo avant la mémoire. Le moteur
installé pèse 279 Mo, et rien de tout cela n'est embarqué.

Les deux options sont ouvertes, et le point d'insertion est unique :
RuntimeLocation::resolve dans src-tauri/src/runtime.rs.

Le sidecar mémoire, lui, est déjà embarqué : le binaire porte l'archive
PocketBase épinglée (12 Mo) plus les plugins et la migration, et il la vérifie
avant de l'exécuter. C'est le prix d'une mémoire qui marche hors ligne dès le
premier lancement, sans second exécutable à signer dans le bundle — l'archive est une donnée, pas un binaire imbriqué, et codesign -v NewPi.app reste valid on disk.

Contribuer

Les contributions sont bienvenues, et le dépôt est volontairement strict sur ce
qu'il accepte : le code, les commentaires et les messages de commit sont en
anglais, tout ce que l'utilisateur lit est en français. AGENTS.md est la carte
du dépôt pour un agent comme pour une personne : lisez-le avant de changer
quelque chose, puis CONTRIBUTING.md pour la mise en route et le portail de
tests.

Le point d'entrée est court : ouvrez une issue pour décrire le problème ou
l'idée avant d'écrire du code, gardez un commit par intention, et faites passer
pnpm test — c'est exactement ce que GitHub Actions rejoue. Une modification
qui touche une affirmation de ce README ou de docs/ doit mettre l'affirmation
à jour dans le même commit.

Sécurité

NewPi lance un moteur qui détient un jeton d'accès local et écrit dans une base
de mémoire : la façon dont ces secrets circulent est une propriété du produit,
pas un détail. Les invariants — la page ne reçoit jamais un secret, la page ne peut pas nommer un projet, le patch de lancement ne transporte aucune configuration secrète — sont décrits dans SECURITY.md, avec la marche à suivre pour signaler une faille en privé plutôt que dans une issue publique.

Licence

NewPi est distribué sous licence MIT : voir LICENSE. Vous pouvez
l'utiliser, le modifier et le redistribuer, y compris commercialement, à
condition de conserver la notice de copyright.

NewPi n'est pas affilié à DeepSeek. Le moteur DeepSeek Harness (dsh) est
installé séparément et garde sa propre licence ; ce dépôt ne le modifie ni ne le
redistribue. PocketBase est distribué sous licence MIT par ses auteurs, et
l'archive épinglée est vérifiée par empreinte avant d'être exécutée.
预览分享卡片长什么样 ↓cyril-belin/newpi
分享 · DeepSeek Harness Hub
cyril-belin/newpi

在原生 macOS 窗口中管理项目记忆与引擎能力

这是你的仓库?认领条目

回到顶部