mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。
## 1. 注入标志位(内核侧)
- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
no_memory / context_policy / cleaner_name;策略在入口**校验**,
非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
(零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。
## 2. 修掉「输入无条件裁剪」这个真缺陷
eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:
优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
> 默认**不裁剪**
查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。
## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)
原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。
现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。
顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。
## 4. homed 放弃 Windows 原生,改走 WSL2
插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。
- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
**不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
procEnvForShm 返回 nil。顺手修掉两处长期编译错误
(cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
**linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
(/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
(否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
目标下明确拒绝。
## 5. 插件调用点统一写明意图
- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
**带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。
## 6. 版本
meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。
## 测试
- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
**跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
落盘幂等、内容哈希稳定。
验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
./internal/agent/{core,io}/... ./pkg/...
513 lines
12 KiB
Go
513 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// Todo 待办条目:会被主动提醒
|
||
type Todo struct {
|
||
ID int64 `json:"id"`
|
||
Content string `json:"content"`
|
||
CreatedAt int64 `json:"created_at"`
|
||
Done bool `json:"done"`
|
||
}
|
||
|
||
// Memo 备忘录条目:纯记事,不主动提醒
|
||
type Memo struct {
|
||
ID int64 `json:"id"`
|
||
Content string `json:"content"`
|
||
CreatedAt int64 `json:"created_at"`
|
||
}
|
||
|
||
type Plugin struct {
|
||
name string
|
||
sdk *sdk.PluginSDK
|
||
mu sync.RWMutex
|
||
todos []Todo
|
||
nextTID int64
|
||
memos []Memo
|
||
nextMID int64
|
||
todoPath string
|
||
memoPath string
|
||
stopCh chan struct{}
|
||
tp string
|
||
}
|
||
|
||
func (p *Plugin) Name() string { return p.name }
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
s.SetAutoRestart(true)
|
||
p.sdk = s
|
||
p.tp = p.name + "_"
|
||
|
||
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
|
||
if err != nil || dataDirVal == "" {
|
||
dataDirVal = "."
|
||
}
|
||
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
|
||
}
|
||
p.todoPath = filepath.Join(dir, "todos.json")
|
||
p.memoPath = filepath.Join(dir, "memos.json")
|
||
p.loadTodos()
|
||
p.loadMemos()
|
||
|
||
// 卸载(删除)时清理数据文件;重载不触发
|
||
s.RegisterOnRemoveHandler(p.cleanupData)
|
||
|
||
// ── 待办(会被主动提醒)──
|
||
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
|
||
Name: p.tp + "todo_add",
|
||
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
|
||
},
|
||
"required": []string{"content"},
|
||
},
|
||
}, p.handleTodoAdd)
|
||
|
||
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
|
||
Name: p.tp + "todo_complete",
|
||
Description: "将指定ID的待办标记为已完成(不再提醒)。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||
},
|
||
"required": []string{"id"},
|
||
},
|
||
}, p.handleTodoComplete)
|
||
|
||
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
|
||
Name: p.tp + "todo_list",
|
||
Description: "列出所有未完成的待办事项,包含ID、内容和创建时间。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{},
|
||
},
|
||
}, p.handleTodoList)
|
||
|
||
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
|
||
Name: p.tp + "todo_delete",
|
||
Description: "删除指定ID的待办事项(包括已完成的)。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
|
||
},
|
||
"required": []string{"id"},
|
||
},
|
||
}, p.handleTodoDelete)
|
||
|
||
// ── 备忘(纯记事,不提醒)──
|
||
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
|
||
Name: p.tp + "memo_create",
|
||
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
|
||
},
|
||
"required": []string{"content"},
|
||
},
|
||
}, p.handleMemoCreate)
|
||
|
||
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
|
||
Name: p.tp + "memo_list",
|
||
Description: "列出所有备忘录,包含ID、内容和创建时间。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{},
|
||
},
|
||
}, p.handleMemoList)
|
||
|
||
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
|
||
Name: p.tp + "memo_delete",
|
||
Description: "删除指定ID的备忘录。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
|
||
},
|
||
"required": []string{"id"},
|
||
},
|
||
}, p.handleMemoDelete)
|
||
|
||
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
|
||
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
|
||
go p.periodicCheck()
|
||
|
||
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) Stop() error {
|
||
close(p.stopCh)
|
||
p.saveTodos()
|
||
p.saveMemos()
|
||
log.Printf("[%s] stopped", p.name)
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) loadTodos() {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
data, err := os.ReadFile(p.todoPath)
|
||
if err != nil {
|
||
p.todos = []Todo{}
|
||
p.nextTID = 1
|
||
return
|
||
}
|
||
var store struct {
|
||
Todos []Todo `json:"todos"`
|
||
NextID int64 `json:"next_id"`
|
||
}
|
||
if json.Unmarshal(data, &store) != nil {
|
||
p.todos = []Todo{}
|
||
p.nextTID = 1
|
||
return
|
||
}
|
||
p.todos = store.Todos
|
||
p.nextTID = store.NextID
|
||
if p.todos == nil {
|
||
p.todos = []Todo{}
|
||
}
|
||
if p.nextTID < 1 {
|
||
p.nextTID = 1
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) loadMemos() {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
data, err := os.ReadFile(p.memoPath)
|
||
if err != nil {
|
||
p.memos = []Memo{}
|
||
p.nextMID = 1
|
||
return
|
||
}
|
||
var store struct {
|
||
Memos []Memo `json:"memos"`
|
||
NextID int64 `json:"next_id"`
|
||
}
|
||
if json.Unmarshal(data, &store) != nil {
|
||
p.memos = []Memo{}
|
||
p.nextMID = 1
|
||
return
|
||
}
|
||
p.memos = store.Memos
|
||
p.nextMID = store.NextID
|
||
if p.memos == nil {
|
||
p.memos = []Memo{}
|
||
}
|
||
if p.nextMID < 1 {
|
||
p.nextMID = 1
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) saveTodos() {
|
||
p.mu.RLock()
|
||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||
"todos": p.todos,
|
||
"next_id": p.nextTID,
|
||
}, "", " ")
|
||
p.mu.RUnlock()
|
||
atomicWriteJSON(p.todoPath, data)
|
||
}
|
||
|
||
func (p *Plugin) saveMemos() {
|
||
p.mu.RLock()
|
||
data, _ := json.MarshalIndent(map[string]interface{}{
|
||
"memos": p.memos,
|
||
"next_id": p.nextMID,
|
||
}, "", " ")
|
||
p.mu.RUnlock()
|
||
atomicWriteJSON(p.memoPath, data)
|
||
}
|
||
|
||
// ── 待办:未完成计数与提醒 ──
|
||
|
||
func (p *Plugin) pendingTodoCount() int {
|
||
p.mu.RLock()
|
||
defer p.mu.RUnlock()
|
||
n := 0
|
||
for _, t := range p.todos {
|
||
if !t.Done {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func (p *Plugin) pendingTodos() []Todo {
|
||
p.mu.RLock()
|
||
defer p.mu.RUnlock()
|
||
var out []Todo
|
||
for _, t := range p.todos {
|
||
if !t.Done {
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
|
||
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
|
||
n := p.pendingTodoCount()
|
||
if n == 0 {
|
||
return nil
|
||
}
|
||
ctx.Lock()
|
||
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
|
||
"role": "system",
|
||
"content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
|
||
})
|
||
ctx.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
|
||
func (p *Plugin) periodicCheck() {
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-p.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
n := p.pendingTodoCount()
|
||
if n == 0 {
|
||
continue
|
||
}
|
||
if p.sdk != nil {
|
||
// NoMemory:这是定时提醒,不是记忆内容。
|
||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 待办工具 ──
|
||
|
||
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
|
||
content, _ := args["content"].(string)
|
||
if content == "" {
|
||
return errorResult("content is required"), nil
|
||
}
|
||
|
||
p.mu.Lock()
|
||
todo := Todo{
|
||
ID: p.nextTID,
|
||
Content: content,
|
||
CreatedAt: time.Now().Unix(),
|
||
Done: false,
|
||
}
|
||
p.nextTID++
|
||
p.todos = append(p.todos, todo)
|
||
p.mu.Unlock()
|
||
p.saveTodos()
|
||
|
||
return map[string]interface{}{
|
||
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
|
||
"id": todo.ID,
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) handleTodoComplete(args map[string]interface{}) (interface{}, error) {
|
||
id, ok := args["id"].(float64)
|
||
if !ok {
|
||
return errorResult("id is required"), nil
|
||
}
|
||
|
||
p.mu.Lock()
|
||
found := false
|
||
for i := range p.todos {
|
||
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
|
||
p.todos[i].Done = true
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
p.mu.Unlock()
|
||
|
||
if !found {
|
||
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
|
||
}
|
||
p.saveTodos()
|
||
|
||
return map[string]interface{}{
|
||
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
|
||
todos := p.pendingTodos()
|
||
if len(todos) == 0 {
|
||
return map[string]interface{}{
|
||
"content": "暂无未完成的待办",
|
||
}, nil
|
||
}
|
||
|
||
var sb strings.Builder
|
||
for i, t := range todos {
|
||
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
|
||
if i > 0 {
|
||
sb.WriteString("\n")
|
||
}
|
||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"content": sb.String(),
|
||
"count": len(todos),
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) handleTodoDelete(args map[string]interface{}) (interface{}, error) {
|
||
id, ok := args["id"].(float64)
|
||
if !ok {
|
||
return errorResult("id is required"), nil
|
||
}
|
||
|
||
p.mu.Lock()
|
||
found := false
|
||
for i := range p.todos {
|
||
if p.todos[i].ID == int64(id) {
|
||
p.todos = append(p.todos[:i], p.todos[i+1:]...)
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
p.mu.Unlock()
|
||
|
||
if !found {
|
||
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
|
||
}
|
||
p.saveTodos()
|
||
|
||
return map[string]interface{}{
|
||
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
|
||
}, nil
|
||
}
|
||
|
||
// ── 备忘工具 ──
|
||
|
||
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
|
||
content, _ := args["content"].(string)
|
||
if content == "" {
|
||
return errorResult("content is required"), nil
|
||
}
|
||
|
||
p.mu.Lock()
|
||
memo := Memo{
|
||
ID: p.nextMID,
|
||
Content: content,
|
||
CreatedAt: time.Now().Unix(),
|
||
}
|
||
p.nextMID++
|
||
p.memos = append(p.memos, memo)
|
||
p.mu.Unlock()
|
||
p.saveMemos()
|
||
|
||
return map[string]interface{}{
|
||
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
|
||
"id": memo.ID,
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
|
||
id, ok := args["id"].(float64)
|
||
if !ok {
|
||
return errorResult("id is required"), nil
|
||
}
|
||
|
||
p.mu.Lock()
|
||
found := false
|
||
for i := range p.memos {
|
||
if p.memos[i].ID == int64(id) {
|
||
p.memos = append(p.memos[:i], p.memos[i+1:]...)
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
p.mu.Unlock()
|
||
|
||
if !found {
|
||
return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil
|
||
}
|
||
p.saveMemos()
|
||
|
||
return map[string]interface{}{
|
||
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
|
||
}, nil
|
||
}
|
||
|
||
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
|
||
p.mu.RLock()
|
||
memos := append([]Memo{}, p.memos...)
|
||
p.mu.RUnlock()
|
||
|
||
if len(memos) == 0 {
|
||
return map[string]interface{}{
|
||
"content": "暂无备忘录",
|
||
}, nil
|
||
}
|
||
|
||
var sb strings.Builder
|
||
for i, m := range memos {
|
||
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
|
||
if i > 0 {
|
||
sb.WriteString("\n")
|
||
}
|
||
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"content": sb.String(),
|
||
"count": len(memos),
|
||
}, nil
|
||
}
|
||
|
||
func errorResult(msg string) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"isError": true,
|
||
"content": msg,
|
||
}
|
||
}
|
||
|
||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return &Plugin{name: name, stopCh: make(chan struct{})}, nil
|
||
}
|
||
|
||
// cleanupData 卸载时清理数据文件(待办 + 备忘)
|
||
func (p *Plugin) cleanupData() {
|
||
if p.todoPath != "" {
|
||
os.Remove(p.todoPath)
|
||
}
|
||
if p.memoPath != "" {
|
||
os.Remove(p.memoPath)
|
||
}
|
||
}
|
||
|
||
// atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。
|
||
func atomicWriteJSON(path string, data []byte) error {
|
||
tmp := path + ".tmp"
|
||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, path)
|
||
}
|