threadnote 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -17
- package/config/post-update-migrations.json +23 -0
- package/dist/mcp_server.cjs +254 -53
- package/dist/threadnote.cjs +1198 -436
- package/docs/agent-instructions.md +47 -4
- package/docs/migration.md +26 -10
- package/docs/rollout.md +1 -1
- package/docs/security.md +5 -4
- package/docs/troubleshooting.md +11 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,12 +7,17 @@ and it does not index whole repositories by default.
|
|
|
7
7
|
## Real-World Uses
|
|
8
8
|
|
|
9
9
|
**Want to continue work in a fresh agent session?**
|
|
10
|
-
`threadnote install` adds user-level Codex, Claude, and
|
|
10
|
+
`threadnote install` adds user-level Codex, Claude, Cursor, and Copilot instructions so new agents automatically recall recent handoffs and relevant memories before they start changing code.
|
|
11
|
+
|
|
12
|
+
**Working on a feature branch over several sessions?**
|
|
13
|
+
Agents recall the branch handoff for current status, then recall durable feature memories for the design, decisions,
|
|
14
|
+
interfaces, and gotchas behind the feature. As useful implementation knowledge appears, agents update the durable feature
|
|
15
|
+
memory instead of leaving everything buried in transient handoffs.
|
|
11
16
|
|
|
12
17
|
**Implemented a feature a while ago and need to pick it up again?**
|
|
13
18
|
Ask the agent to recall the feature, branch, or repo. Threadnote returns auditable `viking://` pointers that the agent can read before deciding what still matters.
|
|
14
19
|
|
|
15
|
-
**Switching between Codex, Claude, and
|
|
20
|
+
**Switching between Codex, Claude, Cursor, and Copilot?**\
|
|
16
21
|
Install the MCP adapter for each agent you use. The user-level instructions tell agents to store a handoff before they pause, so the next agent can search the same local memory layer instead of reconstructing context
|
|
17
22
|
from chat history.
|
|
18
23
|
|
|
@@ -33,6 +38,43 @@ keeping future recall sharper without losing useful detail.
|
|
|
33
38
|
Use `threadnote remember --replace <uri>` or `threadnote handoff --replace <uri>` to keep one current-state memory fresh
|
|
34
39
|
instead of accumulating near-duplicate progress notes.
|
|
35
40
|
|
|
41
|
+
## Memory Lifecycle
|
|
42
|
+
|
|
43
|
+
Threadnote separates current durable knowledge from the historical handoff trail.
|
|
44
|
+
|
|
45
|
+
New `remember` records default to `kind: durable` and `status: active`. New `handoff` records use `kind: handoff` and
|
|
46
|
+
`status: active`. Add `--project` and `--topic` when the memory represents an ongoing issue or stable fact:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
threadnote remember --project threadnote --topic install-health --text "Install/repair waits for OpenViking health."
|
|
50
|
+
threadnote handoff --project threadnote --topic lifecycle-storage --task "Implement lifecycle-aware memories"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Active memories with the same project/topic write to a stable lifecycle path, so later updates replace the current
|
|
54
|
+
version instead of creating another timestamped note. Untagged memories still use timestamped files when you want a
|
|
55
|
+
historical trail.
|
|
56
|
+
|
|
57
|
+
For feature branches, keep a durable feature memory and an active handoff side by side with the same project/topic:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
threadnote remember --kind durable --project my-repo --topic feature-x --text "Feature knowledge: ..."
|
|
61
|
+
threadnote handoff --project my-repo --topic feature-x --task "Current status for feature X"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Agents should update the durable feature memory when valuable implementation knowledge changes, and update the handoff
|
|
65
|
+
when current status, tests, blockers, or next steps change.
|
|
66
|
+
|
|
67
|
+
Use `archive` when an old handoff is useful for provenance but should stop being treated as current working context:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
threadnote archive viking://user/example/memories/handoffs/active/threadnote/lifecycle-storage.md
|
|
71
|
+
threadnote recall --query "threadnote lifecycle storage"
|
|
72
|
+
threadnote recall --query "threadnote lifecycle storage" --include-archived
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If OpenViking is still processing the original file, `archive` keeps the archived copy and tells you to retry
|
|
76
|
+
`threadnote forget <uri>` later.
|
|
77
|
+
|
|
36
78
|
## Why Not Just CLAUDE.md Or AGENTS.md?
|
|
37
79
|
|
|
38
80
|
Use them. Threadnote is not a replacement for checked-in instructions.
|
|
@@ -45,11 +87,12 @@ refactor, what an on-call investigation concluded, which workaround was verified
|
|
|
45
87
|
memories should be compacted. Putting that history into instruction files makes them noisy, stale, and expensive to load
|
|
46
88
|
into every context window.
|
|
47
89
|
|
|
48
|
-
Threadnote keeps that moving layer local, searchable, and shared across agents. Agents recall only the relevant
|
|
49
|
-
handoffs, and skill/resource pointers when they need them, while the source files stay authoritative
|
|
90
|
+
Threadnote keeps that moving layer local, searchable, and shared across agents. Agents recall only the relevant durable
|
|
91
|
+
feature memories, handoffs, and skill/resource pointers when they need them, while the source files stay authoritative
|
|
92
|
+
for project rules.
|
|
50
93
|
|
|
51
|
-
The split is simple: put durable repo policy in `CLAUDE.md`/`AGENTS.md`; put task history, handoffs,
|
|
52
|
-
facts, and local cross-agent memory in Threadnote.
|
|
94
|
+
The split is simple: put durable repo policy in `CLAUDE.md`/`AGENTS.md`; put feature knowledge, task history, handoffs,
|
|
95
|
+
personal workflow facts, and local cross-agent memory in Threadnote.
|
|
53
96
|
|
|
54
97
|
## Safety Model
|
|
55
98
|
|
|
@@ -60,8 +103,9 @@ facts, and local cross-agent memory in Threadnote.
|
|
|
60
103
|
- Redaction: known config files such as `.mcp.json`, `config.toml`, and settings JSON are copied through a redactor
|
|
61
104
|
before import.
|
|
62
105
|
- Secret scanning: candidate files are skipped if common token or private-key patterns remain after redaction.
|
|
63
|
-
- User instructions: `install` upserts
|
|
64
|
-
`~/.cursor/rules/threadnote.md` without replacing existing
|
|
106
|
+
- User instructions: `install` upserts managed Threadnote guidance in `~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md`,
|
|
107
|
+
`~/.cursor/rules/threadnote.md`, and `~/.copilot/instructions/threadnote.instructions.md` without replacing existing
|
|
108
|
+
personal instructions.
|
|
65
109
|
- Agent config changes are explicit: `mcp-install` prints commands and snippets by default; use `--apply` to run them.
|
|
66
110
|
|
|
67
111
|
## Install
|
|
@@ -118,7 +162,16 @@ threadnote update --check
|
|
|
118
162
|
threadnote update --dry-run
|
|
119
163
|
```
|
|
120
164
|
|
|
121
|
-
After updating, restart Cursor, Codex, Claude, or open a fresh agent session so MCP tools reload.
|
|
165
|
+
After updating, restart Cursor, Copilot, Codex, Claude, or open a fresh agent session so MCP tools reload.
|
|
166
|
+
|
|
167
|
+
Some releases include post-update memory migrations. `threadnote update` runs the new package's migration prompt after
|
|
168
|
+
repair, explains what will change, and asks before applying it. Use `threadnote update --yes` for unattended local
|
|
169
|
+
migrations or `threadnote update --no-post-update` to skip them.
|
|
170
|
+
|
|
171
|
+
Applied migrations are tracked by id under `THREADNOTE_HOME`, and migration commands are written to be safe to rerun.
|
|
172
|
+
|
|
173
|
+
If you update from an older Threadnote version that only knew how to run `repair`, the new repair step will still detect
|
|
174
|
+
applicable migrations and print the manual command to run next.
|
|
122
175
|
|
|
123
176
|
### MCP
|
|
124
177
|
|
|
@@ -131,6 +184,7 @@ Dry-run examples:
|
|
|
131
184
|
threadnote mcp-install codex
|
|
132
185
|
threadnote mcp-install claude
|
|
133
186
|
threadnote mcp-install cursor
|
|
187
|
+
threadnote mcp-install copilot
|
|
134
188
|
```
|
|
135
189
|
|
|
136
190
|
Apply after review:
|
|
@@ -139,11 +193,14 @@ Apply after review:
|
|
|
139
193
|
threadnote mcp-install codex --apply
|
|
140
194
|
threadnote mcp-install claude --apply
|
|
141
195
|
threadnote mcp-install cursor --apply
|
|
196
|
+
threadnote mcp-install copilot --apply
|
|
142
197
|
```
|
|
143
198
|
|
|
144
199
|
Claude installs at `user` scope by default so the same OpenViking MCP server is available from any repo or worktree.
|
|
145
200
|
Use `--scope local` or `--scope project` only when you intentionally want repo-scoped Claude MCP config.
|
|
146
201
|
Cursor installs by updating the global `~/.cursor/mcp.json` file.
|
|
202
|
+
Copilot installs by updating the VS Code user-profile `mcp.json` file. Set `THREADNOTE_COPILOT_MCP_CONFIG` if you use a
|
|
203
|
+
custom VS Code profile or want to test against a temporary file.
|
|
147
204
|
|
|
148
205
|
If the package or checkout that originally installed `threadnote` has moved, run repair:
|
|
149
206
|
|
|
@@ -163,6 +220,7 @@ claude mcp add threadnote -- threadnote-mcp-server
|
|
|
163
220
|
```
|
|
164
221
|
|
|
165
222
|
Cursor uses the equivalent entry in `~/.cursor/mcp.json`.
|
|
223
|
+
Copilot uses the VS Code MCP `servers` entry in the user-profile `mcp.json`.
|
|
166
224
|
|
|
167
225
|
If a future OpenViking build exposes a healthy native endpoint, install it explicitly:
|
|
168
226
|
|
|
@@ -219,8 +277,8 @@ This is it! Start working with your agents as usual. The agent will automaticall
|
|
|
219
277
|
`--no-start` to skip the health check.
|
|
220
278
|
- `update`: updates the published Threadnote package, then runs `repair` so shims and MCP config point at the new
|
|
221
279
|
version.
|
|
222
|
-
- `repair`: fixes install/config/shim/manifest/server health issues and rewrites Codex/Claude/Cursor MCP configs
|
|
223
|
-
current checkout.
|
|
280
|
+
- `repair`: fixes install/config/shim/manifest/server health issues and rewrites Codex/Claude/Cursor/Copilot MCP configs
|
|
281
|
+
from the current checkout.
|
|
224
282
|
- `start`: starts `openviking-server` on `127.0.0.1:1933`.
|
|
225
283
|
- `stop`: stops the detached server pid or macOS LaunchAgent.
|
|
226
284
|
- `uninstall`: removes Threadnote shims, MCP config, launchd config, and managed user instructions. Memories are
|
|
@@ -229,17 +287,23 @@ This is it! Start working with your agents as usual. The agent will automaticall
|
|
|
229
287
|
- `seed`: imports curated repo guidance and docs from the manifest.
|
|
230
288
|
- `seed-skills`: imports global and repo-local `SKILL.md` files as a searchable resource catalog so agents can discover
|
|
231
289
|
reusable workflow guidance. Use `seed-skills --native` only after configuring a working VLM provider.
|
|
232
|
-
- `mcp-install codex|claude|cursor`: installs or prints OpenViking MCP configuration for Codex, Claude,
|
|
290
|
+
- `mcp-install codex|claude|cursor|copilot`: installs or prints OpenViking MCP configuration for Codex, Claude, Cursor,
|
|
291
|
+
or GitHub Copilot in VS Code.
|
|
233
292
|
- `remember`: stores a durable memory. Use `--replace <uri>` to store an updated memory and remove a superseded one
|
|
234
|
-
after the new memory succeeds.
|
|
293
|
+
after the new memory succeeds. Use `--kind`, `--project`, and `--topic` to store lifecycle-aware current knowledge.
|
|
235
294
|
- `migrate-memories`: migrates legacy session-only `MEMORY` and `HANDOFF` records into durable memory files. Run
|
|
236
295
|
`migrate-memories --dry-run` first; use `--all-accounts` when importing from older local OpenViking accounts.
|
|
296
|
+
- `migrate-lifecycle`: moves clear legacy handoff memories from the old events path into archived lifecycle handoff
|
|
297
|
+
paths. It dry-runs by default; use `--apply` after reviewing the output.
|
|
237
298
|
- `recall`: searches shared OpenViking context. It infers repo or skill scope from queries like
|
|
238
|
-
`skills for api service`; use `--uri` or `--no-infer-scope` to override.
|
|
299
|
+
`skills for api service`; use `--uri` or `--no-infer-scope` to override. Exact durable-memory matches skip archived
|
|
300
|
+
lifecycle paths unless `--include-archived` is passed.
|
|
239
301
|
- `read`: reads a `viking://` URI returned by `recall` or `list`.
|
|
240
302
|
- `list` / `ls`: lists a `viking://` directory.
|
|
241
303
|
- `handoff`: stores current git state and next-step notes as a durable handoff. Use `--replace <uri>` to update the
|
|
242
|
-
current handoff for the same active issue.
|
|
304
|
+
current handoff for the same active issue, or `--project` and `--topic` to keep one active handoff file updated.
|
|
305
|
+
- `archive`: copies a memory into the archived lifecycle tree, then removes the original after the archive write
|
|
306
|
+
succeeds.
|
|
243
307
|
- `forget`: removes a `viking://` URI.
|
|
244
308
|
- `export-pack` / `import-pack`: moves local context through `.ovpack` files.
|
|
245
309
|
|
|
@@ -258,8 +322,9 @@ npm run threadnote -- install
|
|
|
258
322
|
```
|
|
259
323
|
|
|
260
324
|
`install` writes a small command shim to `~/.local/bin/threadnote` by default and upserts user-level agent guidance in
|
|
261
|
-
`~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md`,
|
|
262
|
-
any repo or working
|
|
325
|
+
`~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md`, `~/.cursor/rules/threadnote.md`, and
|
|
326
|
+
`~/.copilot/instructions/threadnote.instructions.md`. After that, use the short command from any repo or working
|
|
327
|
+
directory:
|
|
263
328
|
|
|
264
329
|
```bash
|
|
265
330
|
threadnote doctor --dry-run
|
|
@@ -288,6 +353,7 @@ Environment variables:
|
|
|
288
353
|
- `THREADNOTE_NPM_REGISTRY`: npm registry used by the installer and updater, default `https://registry.npmjs.org/`.
|
|
289
354
|
- `THREADNOTE_NO_UPDATE_CHECK`: disables opportunistic update notifications.
|
|
290
355
|
- `THREADNOTE_BIN_DIR`: directory for the `threadnote` shim, default `~/.local/bin`.
|
|
356
|
+
- `THREADNOTE_COPILOT_MCP_CONFIG`: explicit VS Code/Copilot `mcp.json` path for `mcp-install copilot`.
|
|
291
357
|
- `THREADNOTE_HOST`: local bind host, default `127.0.0.1`.
|
|
292
358
|
- `THREADNOTE_PORT`: local bind port, default `1933`.
|
|
293
359
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"migrations": [
|
|
4
|
+
{
|
|
5
|
+
"id": "memory-lifecycle-handoff-archive-v1",
|
|
6
|
+
"introducedIn": "0.3.7",
|
|
7
|
+
"title": "Move legacy handoff memories into lifecycle archive paths",
|
|
8
|
+
"description": [
|
|
9
|
+
"Threadnote now separates current durable knowledge from the historical handoff trail.",
|
|
10
|
+
"This migration scans legacy viking://user/<you>/memories/events/*.md files for clear handoff records only.",
|
|
11
|
+
"Matching handoffs are copied to viking://user/<you>/memories/handoffs/archived/<project>/legacy-<hash>.md with lifecycle metadata.",
|
|
12
|
+
"After each archived copy is stored, Threadnote removes the original legacy events file to reduce duplicate recall results.",
|
|
13
|
+
"Ambiguous MEMORY records, preferences, durable facts, incidents, and sensitive-looking content are left untouched for manual review."
|
|
14
|
+
],
|
|
15
|
+
"commandArgs": ["migrate-lifecycle", "--apply"],
|
|
16
|
+
"instructions": [
|
|
17
|
+
"Post-update lifecycle migration finished. Future agents should store durable facts with --kind durable --project <name> --topic <topic> and active work logs with handoff --project <name> --topic <topic>.",
|
|
18
|
+
"If Threadnote reported any original files were still being processed, rerun the printed threadnote forget <uri> command later."
|
|
19
|
+
],
|
|
20
|
+
"requiresLegacyHandoffs": true
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
package/dist/mcp_server.cjs
CHANGED
|
@@ -31095,7 +31095,10 @@ async function main() {
|
|
|
31095
31095
|
"Prefer `recall_context` to find candidate viking:// URIs, then `read_context` files or `list_context` directories.",
|
|
31096
31096
|
'Always pass JSON arguments. Example: recall_context({"query":"current repo latest handoff"}).',
|
|
31097
31097
|
"Older clients may use the compatibility aliases `search`, `read`, and `list`.",
|
|
31098
|
-
|
|
31098
|
+
'For durable facts, store kind="durable"; for current work logs, store kind="handoff" with project/topic so Threadnote keeps one active memory updated.',
|
|
31099
|
+
"When a handoff describes an active branch or feature, recall durable feature memories for the same branch/topic before coding.",
|
|
31100
|
+
"During feature work, update durable feature knowledge when valuable implementation details, decisions, interfaces, or gotchas change.",
|
|
31101
|
+
"When updating the same active issue, pass project/topic or replaceUri to remember_context so duplicate durable memories or handoffs do not accumulate.",
|
|
31099
31102
|
"Do not store secrets, customer data, raw production logs, or credentials."
|
|
31100
31103
|
].join("\n")
|
|
31101
31104
|
}
|
|
@@ -31140,6 +31143,13 @@ function registerTools(server, config2) {
|
|
|
31140
31143
|
"Store a durable Threadnote memory. Required: pass JSON arguments with text."
|
|
31141
31144
|
);
|
|
31142
31145
|
registerStoreTool(server, config2, "store", "Compatibility alias for remember_context.");
|
|
31146
|
+
registerArchiveTool(
|
|
31147
|
+
server,
|
|
31148
|
+
config2,
|
|
31149
|
+
"archive_context",
|
|
31150
|
+
"Archive a memory so it remains readable as provenance but is no longer current working context."
|
|
31151
|
+
);
|
|
31152
|
+
registerArchiveTool(server, config2, "archive", "Compatibility alias for archive_context.");
|
|
31143
31153
|
server.registerTool(
|
|
31144
31154
|
"forget",
|
|
31145
31155
|
{
|
|
@@ -31154,7 +31164,21 @@ function registerTools(server, config2) {
|
|
|
31154
31164
|
if (!checkedUri.ok) {
|
|
31155
31165
|
return checkedUri.error;
|
|
31156
31166
|
}
|
|
31157
|
-
|
|
31167
|
+
try {
|
|
31168
|
+
const ov = await requiredOpenVikingCli();
|
|
31169
|
+
const removed = await removeVikingResourceWithRetry(ov, config2, checkedUri.value);
|
|
31170
|
+
return {
|
|
31171
|
+
content: [
|
|
31172
|
+
{
|
|
31173
|
+
type: "text",
|
|
31174
|
+
text: removed ? `Removed: ${checkedUri.value}` : `Resource is still being processed; retry later: ${checkedUri.value}`
|
|
31175
|
+
}
|
|
31176
|
+
],
|
|
31177
|
+
isError: !removed
|
|
31178
|
+
};
|
|
31179
|
+
} catch (err) {
|
|
31180
|
+
return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
|
|
31181
|
+
}
|
|
31158
31182
|
}
|
|
31159
31183
|
);
|
|
31160
31184
|
server.registerTool(
|
|
@@ -31260,10 +31284,11 @@ function registerSearchTool(server, config2, name, description) {
|
|
|
31260
31284
|
inputSchema: {
|
|
31261
31285
|
query: external_exports.string().optional().describe('Required search query, for example "unity-ui-ccc latest handoff"'),
|
|
31262
31286
|
uri: external_exports.string().optional().describe("Optional viking:// subtree to search"),
|
|
31263
|
-
nodeLimit: external_exports.number().int().positive().max(100).optional().describe("Maximum result count")
|
|
31287
|
+
nodeLimit: external_exports.number().int().positive().max(100).optional().describe("Maximum result count"),
|
|
31288
|
+
includeArchived: external_exports.boolean().optional().describe("Include archived memories in exact durable-memory matches")
|
|
31264
31289
|
}
|
|
31265
31290
|
},
|
|
31266
|
-
async ({ nodeLimit, query, uri }) => {
|
|
31291
|
+
async ({ includeArchived, nodeLimit, query, uri }) => {
|
|
31267
31292
|
const checkedQuery = requiredText(query, name, "query", { query: "unity-ui-ccc latest handoff" });
|
|
31268
31293
|
if (!checkedQuery.ok) {
|
|
31269
31294
|
return checkedQuery.error;
|
|
@@ -31272,22 +31297,26 @@ function registerSearchTool(server, config2, name, description) {
|
|
|
31272
31297
|
if (!checkedUri.ok) {
|
|
31273
31298
|
return checkedUri.error;
|
|
31274
31299
|
}
|
|
31275
|
-
return runRecallTool(
|
|
31276
|
-
|
|
31277
|
-
|
|
31278
|
-
|
|
31279
|
-
|
|
31280
|
-
|
|
31300
|
+
return runRecallTool(
|
|
31301
|
+
config2,
|
|
31302
|
+
[
|
|
31303
|
+
"search",
|
|
31304
|
+
checkedQuery.value,
|
|
31305
|
+
...checkedUri.value ? ["--uri", checkedUri.value] : [],
|
|
31306
|
+
...nodeLimit ? ["--node-limit", String(nodeLimit)] : []
|
|
31307
|
+
],
|
|
31308
|
+
includeArchived === true
|
|
31309
|
+
);
|
|
31281
31310
|
}
|
|
31282
31311
|
);
|
|
31283
31312
|
}
|
|
31284
|
-
async function runRecallTool(config2, args) {
|
|
31313
|
+
async function runRecallTool(config2, args, includeArchived) {
|
|
31285
31314
|
const semanticResult = await runOpenVikingTool(config2, args);
|
|
31286
31315
|
if (semanticResult.isError === true) {
|
|
31287
31316
|
return semanticResult;
|
|
31288
31317
|
}
|
|
31289
31318
|
const query = args[1];
|
|
31290
|
-
const exactMatches = typeof query === "string" ? await exactMemoryMatchesText(config2, query) : void 0;
|
|
31319
|
+
const exactMatches = typeof query === "string" ? await exactMemoryMatchesText(config2, query, includeArchived) : void 0;
|
|
31291
31320
|
if (!exactMatches) {
|
|
31292
31321
|
return semanticResult;
|
|
31293
31322
|
}
|
|
@@ -31303,16 +31332,13 @@ Exact durable memory matches:
|
|
|
31303
31332
|
${exactMatches}` }]
|
|
31304
31333
|
};
|
|
31305
31334
|
}
|
|
31306
|
-
async function exactMemoryMatchesText(config2, query) {
|
|
31335
|
+
async function exactMemoryMatchesText(config2, query, includeArchived) {
|
|
31307
31336
|
const terms = exactRecallTerms(query);
|
|
31308
31337
|
if (terms.length === 0) {
|
|
31309
31338
|
return void 0;
|
|
31310
31339
|
}
|
|
31311
31340
|
const ov = await requiredOpenVikingCli();
|
|
31312
|
-
const scopes =
|
|
31313
|
-
`viking://user/${uriSegment(config2.user)}/memories`,
|
|
31314
|
-
`viking://agent/${uriSegment(config2.agentId)}/memories`
|
|
31315
|
-
];
|
|
31341
|
+
const scopes = exactMemoryScopes(config2, includeArchived);
|
|
31316
31342
|
const outputs = [];
|
|
31317
31343
|
for (const term of terms) {
|
|
31318
31344
|
for (const scope of scopes) {
|
|
@@ -31383,12 +31409,16 @@ function registerStoreTool(server, config2, name, description) {
|
|
|
31383
31409
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
31384
31410
|
description: `${description} Never store secrets, credentials, customer data, or raw logs.`,
|
|
31385
31411
|
inputSchema: {
|
|
31412
|
+
kind: external_exports.enum(["durable", "handoff", "incident", "preference", "smoke"]).optional().describe("Memory lifecycle kind; durable facts and handoffs are most common"),
|
|
31413
|
+
project: external_exports.string().optional().describe("Project/repo namespace, for example threadnote or mobile-native"),
|
|
31386
31414
|
replaceUri: external_exports.string().optional().describe("Optional viking:// memory URI to forget after the new memory is safely stored"),
|
|
31387
31415
|
text: external_exports.string().optional().describe("Required memory text to store"),
|
|
31388
|
-
sourceAgentClient: external_exports.string().optional().describe("Originating client, for example cursor, codex, or claude")
|
|
31416
|
+
sourceAgentClient: external_exports.string().optional().describe("Originating client, for example cursor, copilot, codex, or claude"),
|
|
31417
|
+
status: external_exports.enum(["active", "archived", "superseded"]).optional().describe("Memory lifecycle status"),
|
|
31418
|
+
topic: external_exports.string().optional().describe("Stable topic; active project/topic memories update one file")
|
|
31389
31419
|
}
|
|
31390
31420
|
},
|
|
31391
|
-
async ({ replaceUri, sourceAgentClient, text }) => {
|
|
31421
|
+
async ({ kind, project, replaceUri, sourceAgentClient, status, text, topic }) => {
|
|
31392
31422
|
const checkedText = requiredText(text, name, "text", { text: "Durable engineering note..." });
|
|
31393
31423
|
if (!checkedText.ok) {
|
|
31394
31424
|
return checkedText.error;
|
|
@@ -31397,45 +31427,120 @@ function registerStoreTool(server, config2, name, description) {
|
|
|
31397
31427
|
if (!checkedReplaceUri.ok) {
|
|
31398
31428
|
return checkedReplaceUri.error;
|
|
31399
31429
|
}
|
|
31400
|
-
const
|
|
31401
|
-
"
|
|
31402
|
-
|
|
31403
|
-
|
|
31404
|
-
|
|
31405
|
-
|
|
31406
|
-
|
|
31430
|
+
const metadata = {
|
|
31431
|
+
kind: kind ?? "durable",
|
|
31432
|
+
project: normalizeOptionalMetadata(project),
|
|
31433
|
+
sourceAgentClient: sourceAgentClient ?? "mcp",
|
|
31434
|
+
status: status ?? "active",
|
|
31435
|
+
supersedes: checkedReplaceUri.value,
|
|
31436
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31437
|
+
topic: normalizeOptionalMetadata(topic)
|
|
31438
|
+
};
|
|
31439
|
+
return writeDurableMemory(
|
|
31440
|
+
config2,
|
|
31441
|
+
formatMemoryDocument("MEMORY", metadata, checkedText.value),
|
|
31442
|
+
metadata,
|
|
31443
|
+
checkedReplaceUri.value
|
|
31444
|
+
);
|
|
31445
|
+
}
|
|
31446
|
+
);
|
|
31447
|
+
}
|
|
31448
|
+
function registerArchiveTool(server, config2, name, description) {
|
|
31449
|
+
server.registerTool(
|
|
31450
|
+
name,
|
|
31451
|
+
{
|
|
31452
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
31453
|
+
description: `${description} The archive is written before the original URI is removed.`,
|
|
31454
|
+
inputSchema: {
|
|
31455
|
+
kind: external_exports.enum(["durable", "handoff", "incident", "preference", "smoke"]).optional(),
|
|
31456
|
+
project: external_exports.string().optional().describe("Project/repo namespace for the archived copy"),
|
|
31457
|
+
topic: external_exports.string().optional().describe("Topic for the archived copy"),
|
|
31458
|
+
uri: external_exports.string().optional().describe("Required viking:// memory URI to archive")
|
|
31459
|
+
}
|
|
31460
|
+
},
|
|
31461
|
+
async ({ kind, project, topic, uri }) => {
|
|
31462
|
+
const checkedUri = requiredVikingUri(uri, name, "viking://user/example/memories/handoffs/active/repo/topic.md");
|
|
31463
|
+
if (!checkedUri.ok) {
|
|
31464
|
+
return checkedUri.error;
|
|
31465
|
+
}
|
|
31466
|
+
try {
|
|
31467
|
+
const ov = await requiredOpenVikingCli();
|
|
31468
|
+
const readResult = await runCommand(ov, withIdentity(config2, ["read", checkedUri.value]));
|
|
31469
|
+
const original = readResult.stdout.trim();
|
|
31470
|
+
if (!original) {
|
|
31471
|
+
return {
|
|
31472
|
+
content: [{ type: "text", text: `Could not read ${checkedUri.value} before archiving.` }],
|
|
31473
|
+
isError: true
|
|
31474
|
+
};
|
|
31475
|
+
}
|
|
31476
|
+
const metadata = {
|
|
31477
|
+
archivedFrom: checkedUri.value,
|
|
31478
|
+
kind: kind ?? "handoff",
|
|
31479
|
+
project: normalizeOptionalMetadata(project),
|
|
31480
|
+
sourceAgentClient: "mcp",
|
|
31481
|
+
status: "archived",
|
|
31482
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31483
|
+
topic: normalizeOptionalMetadata(topic)
|
|
31484
|
+
};
|
|
31485
|
+
const archiveResult = await writeDurableMemory(
|
|
31486
|
+
config2,
|
|
31487
|
+
formatMemoryDocument("MEMORY", metadata, ["Archived original Threadnote memory.", "", original].join("\n")),
|
|
31488
|
+
metadata,
|
|
31489
|
+
void 0
|
|
31490
|
+
);
|
|
31491
|
+
if (archiveResult.isError === true) {
|
|
31492
|
+
return archiveResult;
|
|
31493
|
+
}
|
|
31494
|
+
const removedOriginal = await removeVikingResourceWithRetry(ov, config2, checkedUri.value);
|
|
31495
|
+
const [content] = archiveResult.content;
|
|
31496
|
+
const text = content?.type === "text" ? content.text : "Archived memory stored.";
|
|
31497
|
+
return {
|
|
31498
|
+
content: [
|
|
31499
|
+
{
|
|
31500
|
+
type: "text",
|
|
31501
|
+
text: removedOriginal ? `${text}
|
|
31502
|
+
Archived original memory: ${checkedUri.value}` : `${text}
|
|
31503
|
+
Archive stored, but original memory is still processing. Retry later with forget: ${checkedUri.value}`
|
|
31504
|
+
}
|
|
31505
|
+
]
|
|
31506
|
+
};
|
|
31507
|
+
} catch (err) {
|
|
31508
|
+
return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
|
|
31407
31509
|
}
|
|
31408
|
-
return writeDurableMemory(config2, [...header, "", checkedText.value].join("\n"), checkedReplaceUri.value);
|
|
31409
31510
|
}
|
|
31410
31511
|
);
|
|
31411
31512
|
}
|
|
31412
|
-
async function writeDurableMemory(config2, memory, replaceUri) {
|
|
31513
|
+
async function writeDurableMemory(config2, memory, metadata, replaceUri) {
|
|
31413
31514
|
try {
|
|
31414
31515
|
const ov = await requiredOpenVikingCli();
|
|
31415
|
-
const directoryUri =
|
|
31416
|
-
|
|
31417
|
-
|
|
31418
|
-
|
|
31419
|
-
ov,
|
|
31420
|
-
withIdentity(config2, [
|
|
31421
|
-
"mkdir",
|
|
31422
|
-
directoryUri,
|
|
31423
|
-
"--description",
|
|
31424
|
-
"Threadnote durable handoffs, memories, and cross-agent notes."
|
|
31425
|
-
])
|
|
31426
|
-
);
|
|
31427
|
-
}
|
|
31428
|
-
const memoryUri = durableMemoryUri(config2, memory);
|
|
31516
|
+
const directoryUri = memoryDirectoryUri(config2, metadata);
|
|
31517
|
+
await ensureMemoryDirectory(ov, config2, directoryUri);
|
|
31518
|
+
const memoryUri = memoryUriFor(config2, memory, metadata);
|
|
31519
|
+
const writeMode = await memoryWriteMode(ov, config2, memoryUri, metadata);
|
|
31429
31520
|
const result = await runOpenVikingWriteWithRetry(
|
|
31430
31521
|
ov,
|
|
31431
31522
|
config2,
|
|
31432
31523
|
memoryUri,
|
|
31433
|
-
withIdentity(config2, [
|
|
31524
|
+
withIdentity(config2, [
|
|
31525
|
+
"write",
|
|
31526
|
+
memoryUri,
|
|
31527
|
+
"--content",
|
|
31528
|
+
memory,
|
|
31529
|
+
"--mode",
|
|
31530
|
+
writeMode,
|
|
31531
|
+
"--wait",
|
|
31532
|
+
"--timeout",
|
|
31533
|
+
"120"
|
|
31534
|
+
])
|
|
31434
31535
|
);
|
|
31435
|
-
const messages = [`Stored
|
|
31436
|
-
if (replaceUri) {
|
|
31437
|
-
await
|
|
31438
|
-
messages.push(
|
|
31536
|
+
const messages = [`Stored memory: ${memoryUri}`];
|
|
31537
|
+
if (replaceUri && replaceUri !== memoryUri) {
|
|
31538
|
+
const removedReplacedMemory = await removeVikingResourceWithRetry(ov, config2, replaceUri);
|
|
31539
|
+
messages.push(
|
|
31540
|
+
removedReplacedMemory ? `Forgot replaced memory: ${replaceUri}` : `Replacement stored, but superseded memory is still processing. Retry later with forget: ${replaceUri}`
|
|
31541
|
+
);
|
|
31542
|
+
} else if (replaceUri === memoryUri) {
|
|
31543
|
+
messages.push(`Updated existing memory in place: ${memoryUri}`);
|
|
31439
31544
|
}
|
|
31440
31545
|
const text = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
|
|
31441
31546
|
return { content: [{ type: "text", text: [...messages, text].filter(Boolean).join("\n") }] };
|
|
@@ -31464,15 +31569,111 @@ async function vikingResourceExists(ov, config2, uri) {
|
|
|
31464
31569
|
const stat = await runCommand(ov, withIdentity(config2, ["stat", uri]), { allowFailure: true });
|
|
31465
31570
|
return stat.exitCode === 0;
|
|
31466
31571
|
}
|
|
31572
|
+
async function removeVikingResourceWithRetry(ov, config2, uri) {
|
|
31573
|
+
const args = withIdentity(config2, ["rm", uri]);
|
|
31574
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
31575
|
+
const result = await runCommand(ov, args, { allowFailure: true });
|
|
31576
|
+
if (result.exitCode === 0) {
|
|
31577
|
+
return true;
|
|
31578
|
+
}
|
|
31579
|
+
if (isResourceBusy(result.stderr, result.stdout) && attempt === 3) {
|
|
31580
|
+
return false;
|
|
31581
|
+
}
|
|
31582
|
+
if (!isResourceBusy(result.stderr, result.stdout)) {
|
|
31583
|
+
throw new Error(`${ov} ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
31584
|
+
}
|
|
31585
|
+
await sleep(1e3 * (attempt + 1));
|
|
31586
|
+
}
|
|
31587
|
+
return false;
|
|
31588
|
+
}
|
|
31467
31589
|
function isResourceBusy(stderr, stdout) {
|
|
31468
|
-
|
|
31469
|
-
${stdout}`.
|
|
31590
|
+
const output = `${stderr}
|
|
31591
|
+
${stdout}`.toLowerCase();
|
|
31592
|
+
return output.includes("resource is busy") || output.includes("resource is being processed");
|
|
31593
|
+
}
|
|
31594
|
+
async function ensureMemoryDirectory(ov, config2, directoryUri) {
|
|
31595
|
+
for (const uri of vikingDirectoryChain(directoryUri)) {
|
|
31596
|
+
const statResult = await runCommand(ov, withIdentity(config2, ["stat", uri]), { allowFailure: true });
|
|
31597
|
+
if (statResult.exitCode === 0) {
|
|
31598
|
+
continue;
|
|
31599
|
+
}
|
|
31600
|
+
await runCommand(
|
|
31601
|
+
ov,
|
|
31602
|
+
withIdentity(config2, ["mkdir", uri, "--description", "Threadnote lifecycle-aware local memories."])
|
|
31603
|
+
);
|
|
31604
|
+
}
|
|
31470
31605
|
}
|
|
31471
|
-
function
|
|
31472
|
-
|
|
31606
|
+
function memoryUriFor(config2, memory, metadata) {
|
|
31607
|
+
const filename = shouldUseStableMemoryUri(metadata) ? `${uriSegment(metadata.topic ?? "current")}.md` : `threadnote-${safeTimestamp()}-${sha256(memory).slice(0, 12)}.md`;
|
|
31608
|
+
return `${memoryDirectoryUri(config2, metadata)}/${filename}`;
|
|
31609
|
+
}
|
|
31610
|
+
function memoryDirectoryUri(config2, metadata) {
|
|
31611
|
+
const baseUri = `viking://user/${uriSegment(config2.user)}/memories`;
|
|
31612
|
+
const projectSegment = uriSegment(metadata.project ?? "general");
|
|
31613
|
+
switch (metadata.kind) {
|
|
31614
|
+
case "preference":
|
|
31615
|
+
return metadata.status === "active" ? `${baseUri}/preferences` : `${baseUri}/preferences/${uriSegment(metadata.status)}`;
|
|
31616
|
+
case "handoff":
|
|
31617
|
+
return `${baseUri}/handoffs/${uriSegment(metadata.status)}/${projectSegment}`;
|
|
31618
|
+
case "incident":
|
|
31619
|
+
return `${baseUri}/incidents/${uriSegment(metadata.status)}/${projectSegment}`;
|
|
31620
|
+
case "smoke":
|
|
31621
|
+
return `${baseUri}/smoke/${uriSegment(metadata.status)}`;
|
|
31622
|
+
case "durable":
|
|
31623
|
+
return metadata.status === "active" ? `${baseUri}/durable/projects/${projectSegment}` : `${baseUri}/durable/${uriSegment(metadata.status)}/${projectSegment}`;
|
|
31624
|
+
}
|
|
31473
31625
|
}
|
|
31474
|
-
function
|
|
31475
|
-
return
|
|
31626
|
+
function shouldUseStableMemoryUri(metadata) {
|
|
31627
|
+
return metadata.status === "active" && metadata.topic !== void 0 && metadata.kind !== "smoke";
|
|
31628
|
+
}
|
|
31629
|
+
async function memoryWriteMode(ov, config2, memoryUri, metadata) {
|
|
31630
|
+
if (!shouldUseStableMemoryUri(metadata)) {
|
|
31631
|
+
return "create";
|
|
31632
|
+
}
|
|
31633
|
+
return await vikingResourceExists(ov, config2, memoryUri) ? "replace" : "create";
|
|
31634
|
+
}
|
|
31635
|
+
function vikingDirectoryChain(directoryUri) {
|
|
31636
|
+
const prefix = "viking://";
|
|
31637
|
+
if (!directoryUri.startsWith(prefix)) {
|
|
31638
|
+
return [directoryUri];
|
|
31639
|
+
}
|
|
31640
|
+
const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
|
|
31641
|
+
const startIndex = parts[0] === "user" && parts.length > 2 ? 3 : 1;
|
|
31642
|
+
const chain = [];
|
|
31643
|
+
for (let index = startIndex; index <= parts.length; index += 1) {
|
|
31644
|
+
chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
|
|
31645
|
+
}
|
|
31646
|
+
return chain;
|
|
31647
|
+
}
|
|
31648
|
+
function exactMemoryScopes(config2, includeArchived) {
|
|
31649
|
+
const userBase = `viking://user/${uriSegment(config2.user)}/memories`;
|
|
31650
|
+
const scopes = [
|
|
31651
|
+
`${userBase}/preferences`,
|
|
31652
|
+
`${userBase}/durable/projects`,
|
|
31653
|
+
`${userBase}/handoffs/active`,
|
|
31654
|
+
`${userBase}/incidents/active`,
|
|
31655
|
+
`${userBase}/events`,
|
|
31656
|
+
`viking://agent/${uriSegment(config2.agentId)}/memories`
|
|
31657
|
+
];
|
|
31658
|
+
return includeArchived ? [...scopes, `${userBase}/durable/archived`, `${userBase}/handoffs/archived`, `${userBase}/incidents/archived`] : scopes;
|
|
31659
|
+
}
|
|
31660
|
+
function formatMemoryDocument(title, metadata, body) {
|
|
31661
|
+
const header = [
|
|
31662
|
+
title,
|
|
31663
|
+
`kind: ${metadata.kind}`,
|
|
31664
|
+
`status: ${metadata.status}`,
|
|
31665
|
+
metadata.project ? `project: ${metadata.project}` : void 0,
|
|
31666
|
+
metadata.topic ? `topic: ${metadata.topic}` : void 0,
|
|
31667
|
+
`source_agent_client: ${metadata.sourceAgentClient}`,
|
|
31668
|
+
`timestamp: ${metadata.timestamp}`,
|
|
31669
|
+
metadata.supersedes ? `supersedes: ${metadata.supersedes}` : void 0,
|
|
31670
|
+
metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : void 0
|
|
31671
|
+
].filter((line) => line !== void 0);
|
|
31672
|
+
return [...header, "", body.trim()].join("\n");
|
|
31673
|
+
}
|
|
31674
|
+
function normalizeOptionalMetadata(value) {
|
|
31675
|
+
const trimmed = value?.trim();
|
|
31676
|
+
return trimmed ? trimmed : void 0;
|
|
31476
31677
|
}
|
|
31477
31678
|
function uriSegment(value) {
|
|
31478
31679
|
const normalized = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|