threadnote 0.3.5 → 0.3.7
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 +68 -3
- package/config/post-update-migrations.json +23 -0
- package/dist/mcp_server.cjs +258 -49
- package/dist/threadnote.cjs +994 -416
- package/docs/agent-instructions.md +28 -2
- package/docs/migration.md +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,55 @@ Run `threadnote seed-skills` to make local `SKILL.md` guidance discoverable thro
|
|
|
29
29
|
Agents are instructed to compact them into one concise replacement memory and forget only the clearly redundant originals,
|
|
30
30
|
keeping future recall sharper without losing useful detail.
|
|
31
31
|
|
|
32
|
+
**Still working on the same issue?**
|
|
33
|
+
Use `threadnote remember --replace <uri>` or `threadnote handoff --replace <uri>` to keep one current-state memory fresh
|
|
34
|
+
instead of accumulating near-duplicate progress notes.
|
|
35
|
+
|
|
36
|
+
## Memory Lifecycle
|
|
37
|
+
|
|
38
|
+
Threadnote separates current durable knowledge from the historical handoff trail.
|
|
39
|
+
|
|
40
|
+
New `remember` records default to `kind: durable` and `status: active`. New `handoff` records use `kind: handoff` and
|
|
41
|
+
`status: active`. Add `--project` and `--topic` when the memory represents an ongoing issue or stable fact:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
threadnote remember --project threadnote --topic install-health --text "Install/repair waits for OpenViking health."
|
|
45
|
+
threadnote handoff --project threadnote --topic lifecycle-storage --task "Implement lifecycle-aware memories"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Active memories with the same project/topic write to a stable lifecycle path, so later updates replace the current
|
|
49
|
+
version instead of creating another timestamped note. Untagged memories still use timestamped files when you want a
|
|
50
|
+
historical trail.
|
|
51
|
+
|
|
52
|
+
Use `archive` when an old handoff is useful for provenance but should stop being treated as current working context:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
threadnote archive viking://user/example/memories/handoffs/active/threadnote/lifecycle-storage.md
|
|
56
|
+
threadnote recall --query "threadnote lifecycle storage"
|
|
57
|
+
threadnote recall --query "threadnote lifecycle storage" --include-archived
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
If OpenViking is still processing the original file, `archive` keeps the archived copy and tells you to retry
|
|
61
|
+
`threadnote forget <uri>` later.
|
|
62
|
+
|
|
63
|
+
## Why Not Just CLAUDE.md Or AGENTS.md?
|
|
64
|
+
|
|
65
|
+
Use them. Threadnote is not a replacement for checked-in instructions.
|
|
66
|
+
|
|
67
|
+
`CLAUDE.md`, `AGENTS.md`, Cursor rules, and repo docs are the right place for stable, canonical guidance: coding
|
|
68
|
+
standards, test commands, review rules, architecture notes, and anything the whole team should version and discuss.
|
|
69
|
+
|
|
70
|
+
They are less ideal for living context: what happened in the last agent session, which branch was halfway through a
|
|
71
|
+
refactor, what an on-call investigation concluded, which workaround was verified on one machine, or which duplicate
|
|
72
|
+
memories should be compacted. Putting that history into instruction files makes them noisy, stale, and expensive to load
|
|
73
|
+
into every context window.
|
|
74
|
+
|
|
75
|
+
Threadnote keeps that moving layer local, searchable, and shared across agents. Agents recall only the relevant memories,
|
|
76
|
+
handoffs, and skill/resource pointers when they need them, while the source files stay authoritative for project rules.
|
|
77
|
+
|
|
78
|
+
The split is simple: put durable repo policy in `CLAUDE.md`/`AGENTS.md`; put task history, handoffs, personal workflow
|
|
79
|
+
facts, and local cross-agent memory in Threadnote.
|
|
80
|
+
|
|
32
81
|
## Safety Model
|
|
33
82
|
|
|
34
83
|
- Machine writes stay **locally** under `THREADNOTE_HOME`, which defaults to `~/.openviking`.
|
|
@@ -98,6 +147,15 @@ threadnote update --dry-run
|
|
|
98
147
|
|
|
99
148
|
After updating, restart Cursor, Codex, Claude, or open a fresh agent session so MCP tools reload.
|
|
100
149
|
|
|
150
|
+
Some releases include post-update memory migrations. `threadnote update` runs the new package's migration prompt after
|
|
151
|
+
repair, explains what will change, and asks before applying it. Use `threadnote update --yes` for unattended local
|
|
152
|
+
migrations or `threadnote update --no-post-update` to skip them.
|
|
153
|
+
|
|
154
|
+
Applied migrations are tracked by id under `THREADNOTE_HOME`, and migration commands are written to be safe to rerun.
|
|
155
|
+
|
|
156
|
+
If you update from an older Threadnote version that only knew how to run `repair`, the new repair step will still detect
|
|
157
|
+
applicable migrations and print the manual command to run next.
|
|
158
|
+
|
|
101
159
|
### MCP
|
|
102
160
|
|
|
103
161
|
Make the agents you use aware of Threadnote. Use only the MCP install lines for agents you actually use. Open a fresh
|
|
@@ -208,14 +266,21 @@ This is it! Start working with your agents as usual. The agent will automaticall
|
|
|
208
266
|
- `seed-skills`: imports global and repo-local `SKILL.md` files as a searchable resource catalog so agents can discover
|
|
209
267
|
reusable workflow guidance. Use `seed-skills --native` only after configuring a working VLM provider.
|
|
210
268
|
- `mcp-install codex|claude|cursor`: installs or prints OpenViking MCP configuration for Codex, Claude, or Cursor.
|
|
211
|
-
- `remember`: stores a durable memory.
|
|
269
|
+
- `remember`: stores a durable memory. Use `--replace <uri>` to store an updated memory and remove a superseded one
|
|
270
|
+
after the new memory succeeds. Use `--kind`, `--project`, and `--topic` to store lifecycle-aware current knowledge.
|
|
212
271
|
- `migrate-memories`: migrates legacy session-only `MEMORY` and `HANDOFF` records into durable memory files. Run
|
|
213
272
|
`migrate-memories --dry-run` first; use `--all-accounts` when importing from older local OpenViking accounts.
|
|
273
|
+
- `migrate-lifecycle`: moves clear legacy handoff memories from the old events path into archived lifecycle handoff
|
|
274
|
+
paths. It dry-runs by default; use `--apply` after reviewing the output.
|
|
214
275
|
- `recall`: searches shared OpenViking context. It infers repo or skill scope from queries like
|
|
215
|
-
`skills for api service`; use `--uri` or `--no-infer-scope` to override.
|
|
276
|
+
`skills for api service`; use `--uri` or `--no-infer-scope` to override. Exact durable-memory matches skip archived
|
|
277
|
+
lifecycle paths unless `--include-archived` is passed.
|
|
216
278
|
- `read`: reads a `viking://` URI returned by `recall` or `list`.
|
|
217
279
|
- `list` / `ls`: lists a `viking://` directory.
|
|
218
|
-
- `handoff`: stores current git state and next-step notes as a durable handoff.
|
|
280
|
+
- `handoff`: stores current git state and next-step notes as a durable handoff. Use `--replace <uri>` to update the
|
|
281
|
+
current handoff for the same active issue, or `--project` and `--topic` to keep one active handoff file updated.
|
|
282
|
+
- `archive`: copies a memory into the archived lifecycle tree, then removes the original after the archive write
|
|
283
|
+
succeeds.
|
|
219
284
|
- `forget`: removes a `viking://` URI.
|
|
220
285
|
- `export-pack` / `import-pack`: moves local context through `.ovpack` files.
|
|
221
286
|
|
|
@@ -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,6 +31095,8 @@ 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
|
+
'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 updating the same active issue, pass project/topic or replaceUri to remember_context so duplicate handoffs do not accumulate.",
|
|
31098
31100
|
"Do not store secrets, customer data, raw production logs, or credentials."
|
|
31099
31101
|
].join("\n")
|
|
31100
31102
|
}
|
|
@@ -31139,6 +31141,13 @@ function registerTools(server, config2) {
|
|
|
31139
31141
|
"Store a durable Threadnote memory. Required: pass JSON arguments with text."
|
|
31140
31142
|
);
|
|
31141
31143
|
registerStoreTool(server, config2, "store", "Compatibility alias for remember_context.");
|
|
31144
|
+
registerArchiveTool(
|
|
31145
|
+
server,
|
|
31146
|
+
config2,
|
|
31147
|
+
"archive_context",
|
|
31148
|
+
"Archive a memory so it remains readable as provenance but is no longer current working context."
|
|
31149
|
+
);
|
|
31150
|
+
registerArchiveTool(server, config2, "archive", "Compatibility alias for archive_context.");
|
|
31142
31151
|
server.registerTool(
|
|
31143
31152
|
"forget",
|
|
31144
31153
|
{
|
|
@@ -31153,7 +31162,21 @@ function registerTools(server, config2) {
|
|
|
31153
31162
|
if (!checkedUri.ok) {
|
|
31154
31163
|
return checkedUri.error;
|
|
31155
31164
|
}
|
|
31156
|
-
|
|
31165
|
+
try {
|
|
31166
|
+
const ov = await requiredOpenVikingCli();
|
|
31167
|
+
const removed = await removeVikingResourceWithRetry(ov, config2, checkedUri.value);
|
|
31168
|
+
return {
|
|
31169
|
+
content: [
|
|
31170
|
+
{
|
|
31171
|
+
type: "text",
|
|
31172
|
+
text: removed ? `Removed: ${checkedUri.value}` : `Resource is still being processed; retry later: ${checkedUri.value}`
|
|
31173
|
+
}
|
|
31174
|
+
],
|
|
31175
|
+
isError: !removed
|
|
31176
|
+
};
|
|
31177
|
+
} catch (err) {
|
|
31178
|
+
return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
|
|
31179
|
+
}
|
|
31157
31180
|
}
|
|
31158
31181
|
);
|
|
31159
31182
|
server.registerTool(
|
|
@@ -31259,10 +31282,11 @@ function registerSearchTool(server, config2, name, description) {
|
|
|
31259
31282
|
inputSchema: {
|
|
31260
31283
|
query: external_exports.string().optional().describe('Required search query, for example "unity-ui-ccc latest handoff"'),
|
|
31261
31284
|
uri: external_exports.string().optional().describe("Optional viking:// subtree to search"),
|
|
31262
|
-
nodeLimit: external_exports.number().int().positive().max(100).optional().describe("Maximum result count")
|
|
31285
|
+
nodeLimit: external_exports.number().int().positive().max(100).optional().describe("Maximum result count"),
|
|
31286
|
+
includeArchived: external_exports.boolean().optional().describe("Include archived memories in exact durable-memory matches")
|
|
31263
31287
|
}
|
|
31264
31288
|
},
|
|
31265
|
-
async ({ nodeLimit, query, uri }) => {
|
|
31289
|
+
async ({ includeArchived, nodeLimit, query, uri }) => {
|
|
31266
31290
|
const checkedQuery = requiredText(query, name, "query", { query: "unity-ui-ccc latest handoff" });
|
|
31267
31291
|
if (!checkedQuery.ok) {
|
|
31268
31292
|
return checkedQuery.error;
|
|
@@ -31271,22 +31295,26 @@ function registerSearchTool(server, config2, name, description) {
|
|
|
31271
31295
|
if (!checkedUri.ok) {
|
|
31272
31296
|
return checkedUri.error;
|
|
31273
31297
|
}
|
|
31274
|
-
return runRecallTool(
|
|
31275
|
-
|
|
31276
|
-
|
|
31277
|
-
|
|
31278
|
-
|
|
31279
|
-
|
|
31298
|
+
return runRecallTool(
|
|
31299
|
+
config2,
|
|
31300
|
+
[
|
|
31301
|
+
"search",
|
|
31302
|
+
checkedQuery.value,
|
|
31303
|
+
...checkedUri.value ? ["--uri", checkedUri.value] : [],
|
|
31304
|
+
...nodeLimit ? ["--node-limit", String(nodeLimit)] : []
|
|
31305
|
+
],
|
|
31306
|
+
includeArchived === true
|
|
31307
|
+
);
|
|
31280
31308
|
}
|
|
31281
31309
|
);
|
|
31282
31310
|
}
|
|
31283
|
-
async function runRecallTool(config2, args) {
|
|
31311
|
+
async function runRecallTool(config2, args, includeArchived) {
|
|
31284
31312
|
const semanticResult = await runOpenVikingTool(config2, args);
|
|
31285
31313
|
if (semanticResult.isError === true) {
|
|
31286
31314
|
return semanticResult;
|
|
31287
31315
|
}
|
|
31288
31316
|
const query = args[1];
|
|
31289
|
-
const exactMatches = typeof query === "string" ? await exactMemoryMatchesText(config2, query) : void 0;
|
|
31317
|
+
const exactMatches = typeof query === "string" ? await exactMemoryMatchesText(config2, query, includeArchived) : void 0;
|
|
31290
31318
|
if (!exactMatches) {
|
|
31291
31319
|
return semanticResult;
|
|
31292
31320
|
}
|
|
@@ -31302,16 +31330,13 @@ Exact durable memory matches:
|
|
|
31302
31330
|
${exactMatches}` }]
|
|
31303
31331
|
};
|
|
31304
31332
|
}
|
|
31305
|
-
async function exactMemoryMatchesText(config2, query) {
|
|
31333
|
+
async function exactMemoryMatchesText(config2, query, includeArchived) {
|
|
31306
31334
|
const terms = exactRecallTerms(query);
|
|
31307
31335
|
if (terms.length === 0) {
|
|
31308
31336
|
return void 0;
|
|
31309
31337
|
}
|
|
31310
31338
|
const ov = await requiredOpenVikingCli();
|
|
31311
|
-
const scopes =
|
|
31312
|
-
`viking://user/${uriSegment(config2.user)}/memories`,
|
|
31313
|
-
`viking://agent/${uriSegment(config2.agentId)}/memories`
|
|
31314
|
-
];
|
|
31339
|
+
const scopes = exactMemoryScopes(config2, includeArchived);
|
|
31315
31340
|
const outputs = [];
|
|
31316
31341
|
for (const term of terms) {
|
|
31317
31342
|
for (const scope of scopes) {
|
|
@@ -31379,56 +31404,144 @@ function registerStoreTool(server, config2, name, description) {
|
|
|
31379
31404
|
server.registerTool(
|
|
31380
31405
|
name,
|
|
31381
31406
|
{
|
|
31382
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
31407
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
31383
31408
|
description: `${description} Never store secrets, credentials, customer data, or raw logs.`,
|
|
31384
31409
|
inputSchema: {
|
|
31410
|
+
kind: external_exports.enum(["durable", "handoff", "incident", "preference", "smoke"]).optional().describe("Memory lifecycle kind; durable facts and handoffs are most common"),
|
|
31411
|
+
project: external_exports.string().optional().describe("Project/repo namespace, for example threadnote or mobile-native"),
|
|
31412
|
+
replaceUri: external_exports.string().optional().describe("Optional viking:// memory URI to forget after the new memory is safely stored"),
|
|
31385
31413
|
text: external_exports.string().optional().describe("Required memory text to store"),
|
|
31386
|
-
sourceAgentClient: external_exports.string().optional().describe("Originating client, for example cursor, codex, or claude")
|
|
31414
|
+
sourceAgentClient: external_exports.string().optional().describe("Originating client, for example cursor, codex, or claude"),
|
|
31415
|
+
status: external_exports.enum(["active", "archived", "superseded"]).optional().describe("Memory lifecycle status"),
|
|
31416
|
+
topic: external_exports.string().optional().describe("Stable topic; active project/topic memories update one file")
|
|
31387
31417
|
}
|
|
31388
31418
|
},
|
|
31389
|
-
async ({ sourceAgentClient, text }) => {
|
|
31419
|
+
async ({ kind, project, replaceUri, sourceAgentClient, status, text, topic }) => {
|
|
31390
31420
|
const checkedText = requiredText(text, name, "text", { text: "Durable engineering note..." });
|
|
31391
31421
|
if (!checkedText.ok) {
|
|
31392
31422
|
return checkedText.error;
|
|
31393
31423
|
}
|
|
31424
|
+
const checkedReplaceUri = optionalVikingUri(replaceUri, name);
|
|
31425
|
+
if (!checkedReplaceUri.ok) {
|
|
31426
|
+
return checkedReplaceUri.error;
|
|
31427
|
+
}
|
|
31428
|
+
const metadata = {
|
|
31429
|
+
kind: kind ?? "durable",
|
|
31430
|
+
project: normalizeOptionalMetadata(project),
|
|
31431
|
+
sourceAgentClient: sourceAgentClient ?? "mcp",
|
|
31432
|
+
status: status ?? "active",
|
|
31433
|
+
supersedes: checkedReplaceUri.value,
|
|
31434
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31435
|
+
topic: normalizeOptionalMetadata(topic)
|
|
31436
|
+
};
|
|
31394
31437
|
return writeDurableMemory(
|
|
31395
31438
|
config2,
|
|
31396
|
-
|
|
31397
|
-
|
|
31398
|
-
|
|
31399
|
-
`timestamp: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
31400
|
-
"",
|
|
31401
|
-
checkedText.value
|
|
31402
|
-
].join("\n")
|
|
31439
|
+
formatMemoryDocument("MEMORY", metadata, checkedText.value),
|
|
31440
|
+
metadata,
|
|
31441
|
+
checkedReplaceUri.value
|
|
31403
31442
|
);
|
|
31404
31443
|
}
|
|
31405
31444
|
);
|
|
31406
31445
|
}
|
|
31407
|
-
|
|
31446
|
+
function registerArchiveTool(server, config2, name, description) {
|
|
31447
|
+
server.registerTool(
|
|
31448
|
+
name,
|
|
31449
|
+
{
|
|
31450
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
31451
|
+
description: `${description} The archive is written before the original URI is removed.`,
|
|
31452
|
+
inputSchema: {
|
|
31453
|
+
kind: external_exports.enum(["durable", "handoff", "incident", "preference", "smoke"]).optional(),
|
|
31454
|
+
project: external_exports.string().optional().describe("Project/repo namespace for the archived copy"),
|
|
31455
|
+
topic: external_exports.string().optional().describe("Topic for the archived copy"),
|
|
31456
|
+
uri: external_exports.string().optional().describe("Required viking:// memory URI to archive")
|
|
31457
|
+
}
|
|
31458
|
+
},
|
|
31459
|
+
async ({ kind, project, topic, uri }) => {
|
|
31460
|
+
const checkedUri = requiredVikingUri(uri, name, "viking://user/example/memories/handoffs/active/repo/topic.md");
|
|
31461
|
+
if (!checkedUri.ok) {
|
|
31462
|
+
return checkedUri.error;
|
|
31463
|
+
}
|
|
31464
|
+
try {
|
|
31465
|
+
const ov = await requiredOpenVikingCli();
|
|
31466
|
+
const readResult = await runCommand(ov, withIdentity(config2, ["read", checkedUri.value]));
|
|
31467
|
+
const original = readResult.stdout.trim();
|
|
31468
|
+
if (!original) {
|
|
31469
|
+
return {
|
|
31470
|
+
content: [{ type: "text", text: `Could not read ${checkedUri.value} before archiving.` }],
|
|
31471
|
+
isError: true
|
|
31472
|
+
};
|
|
31473
|
+
}
|
|
31474
|
+
const metadata = {
|
|
31475
|
+
archivedFrom: checkedUri.value,
|
|
31476
|
+
kind: kind ?? "handoff",
|
|
31477
|
+
project: normalizeOptionalMetadata(project),
|
|
31478
|
+
sourceAgentClient: "mcp",
|
|
31479
|
+
status: "archived",
|
|
31480
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31481
|
+
topic: normalizeOptionalMetadata(topic)
|
|
31482
|
+
};
|
|
31483
|
+
const archiveResult = await writeDurableMemory(
|
|
31484
|
+
config2,
|
|
31485
|
+
formatMemoryDocument("MEMORY", metadata, ["Archived original Threadnote memory.", "", original].join("\n")),
|
|
31486
|
+
metadata,
|
|
31487
|
+
void 0
|
|
31488
|
+
);
|
|
31489
|
+
if (archiveResult.isError === true) {
|
|
31490
|
+
return archiveResult;
|
|
31491
|
+
}
|
|
31492
|
+
const removedOriginal = await removeVikingResourceWithRetry(ov, config2, checkedUri.value);
|
|
31493
|
+
const [content] = archiveResult.content;
|
|
31494
|
+
const text = content?.type === "text" ? content.text : "Archived memory stored.";
|
|
31495
|
+
return {
|
|
31496
|
+
content: [
|
|
31497
|
+
{
|
|
31498
|
+
type: "text",
|
|
31499
|
+
text: removedOriginal ? `${text}
|
|
31500
|
+
Archived original memory: ${checkedUri.value}` : `${text}
|
|
31501
|
+
Archive stored, but original memory is still processing. Retry later with forget: ${checkedUri.value}`
|
|
31502
|
+
}
|
|
31503
|
+
]
|
|
31504
|
+
};
|
|
31505
|
+
} catch (err) {
|
|
31506
|
+
return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
|
|
31507
|
+
}
|
|
31508
|
+
}
|
|
31509
|
+
);
|
|
31510
|
+
}
|
|
31511
|
+
async function writeDurableMemory(config2, memory, metadata, replaceUri) {
|
|
31408
31512
|
try {
|
|
31409
31513
|
const ov = await requiredOpenVikingCli();
|
|
31410
|
-
const directoryUri =
|
|
31411
|
-
|
|
31412
|
-
|
|
31413
|
-
|
|
31414
|
-
ov,
|
|
31415
|
-
withIdentity(config2, [
|
|
31416
|
-
"mkdir",
|
|
31417
|
-
directoryUri,
|
|
31418
|
-
"--description",
|
|
31419
|
-
"Threadnote durable handoffs, memories, and cross-agent notes."
|
|
31420
|
-
])
|
|
31421
|
-
);
|
|
31422
|
-
}
|
|
31423
|
-
const memoryUri = durableMemoryUri(config2, memory);
|
|
31514
|
+
const directoryUri = memoryDirectoryUri(config2, metadata);
|
|
31515
|
+
await ensureMemoryDirectory(ov, config2, directoryUri);
|
|
31516
|
+
const memoryUri = memoryUriFor(config2, memory, metadata);
|
|
31517
|
+
const writeMode = await memoryWriteMode(ov, config2, memoryUri, metadata);
|
|
31424
31518
|
const result = await runOpenVikingWriteWithRetry(
|
|
31425
31519
|
ov,
|
|
31426
31520
|
config2,
|
|
31427
31521
|
memoryUri,
|
|
31428
|
-
withIdentity(config2, [
|
|
31522
|
+
withIdentity(config2, [
|
|
31523
|
+
"write",
|
|
31524
|
+
memoryUri,
|
|
31525
|
+
"--content",
|
|
31526
|
+
memory,
|
|
31527
|
+
"--mode",
|
|
31528
|
+
writeMode,
|
|
31529
|
+
"--wait",
|
|
31530
|
+
"--timeout",
|
|
31531
|
+
"120"
|
|
31532
|
+
])
|
|
31429
31533
|
);
|
|
31534
|
+
const messages = [`Stored memory: ${memoryUri}`];
|
|
31535
|
+
if (replaceUri && replaceUri !== memoryUri) {
|
|
31536
|
+
const removedReplacedMemory = await removeVikingResourceWithRetry(ov, config2, replaceUri);
|
|
31537
|
+
messages.push(
|
|
31538
|
+
removedReplacedMemory ? `Forgot replaced memory: ${replaceUri}` : `Replacement stored, but superseded memory is still processing. Retry later with forget: ${replaceUri}`
|
|
31539
|
+
);
|
|
31540
|
+
} else if (replaceUri === memoryUri) {
|
|
31541
|
+
messages.push(`Updated existing memory in place: ${memoryUri}`);
|
|
31542
|
+
}
|
|
31430
31543
|
const text = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
|
|
31431
|
-
return { content: [{ type: "text", text: [
|
|
31544
|
+
return { content: [{ type: "text", text: [...messages, text].filter(Boolean).join("\n") }] };
|
|
31432
31545
|
} catch (err) {
|
|
31433
31546
|
return { content: [{ type: "text", text: errorMessage(err) }], isError: true };
|
|
31434
31547
|
}
|
|
@@ -31454,15 +31567,111 @@ async function vikingResourceExists(ov, config2, uri) {
|
|
|
31454
31567
|
const stat = await runCommand(ov, withIdentity(config2, ["stat", uri]), { allowFailure: true });
|
|
31455
31568
|
return stat.exitCode === 0;
|
|
31456
31569
|
}
|
|
31570
|
+
async function removeVikingResourceWithRetry(ov, config2, uri) {
|
|
31571
|
+
const args = withIdentity(config2, ["rm", uri]);
|
|
31572
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
31573
|
+
const result = await runCommand(ov, args, { allowFailure: true });
|
|
31574
|
+
if (result.exitCode === 0) {
|
|
31575
|
+
return true;
|
|
31576
|
+
}
|
|
31577
|
+
if (isResourceBusy(result.stderr, result.stdout) && attempt === 3) {
|
|
31578
|
+
return false;
|
|
31579
|
+
}
|
|
31580
|
+
if (!isResourceBusy(result.stderr, result.stdout)) {
|
|
31581
|
+
throw new Error(`${ov} ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
31582
|
+
}
|
|
31583
|
+
await sleep(1e3 * (attempt + 1));
|
|
31584
|
+
}
|
|
31585
|
+
return false;
|
|
31586
|
+
}
|
|
31457
31587
|
function isResourceBusy(stderr, stdout) {
|
|
31458
|
-
|
|
31459
|
-
${stdout}`.
|
|
31588
|
+
const output = `${stderr}
|
|
31589
|
+
${stdout}`.toLowerCase();
|
|
31590
|
+
return output.includes("resource is busy") || output.includes("resource is being processed");
|
|
31591
|
+
}
|
|
31592
|
+
async function ensureMemoryDirectory(ov, config2, directoryUri) {
|
|
31593
|
+
for (const uri of vikingDirectoryChain(directoryUri)) {
|
|
31594
|
+
const statResult = await runCommand(ov, withIdentity(config2, ["stat", uri]), { allowFailure: true });
|
|
31595
|
+
if (statResult.exitCode === 0) {
|
|
31596
|
+
continue;
|
|
31597
|
+
}
|
|
31598
|
+
await runCommand(
|
|
31599
|
+
ov,
|
|
31600
|
+
withIdentity(config2, ["mkdir", uri, "--description", "Threadnote lifecycle-aware local memories."])
|
|
31601
|
+
);
|
|
31602
|
+
}
|
|
31603
|
+
}
|
|
31604
|
+
function memoryUriFor(config2, memory, metadata) {
|
|
31605
|
+
const filename = shouldUseStableMemoryUri(metadata) ? `${uriSegment(metadata.topic ?? "current")}.md` : `threadnote-${safeTimestamp()}-${sha256(memory).slice(0, 12)}.md`;
|
|
31606
|
+
return `${memoryDirectoryUri(config2, metadata)}/${filename}`;
|
|
31460
31607
|
}
|
|
31461
|
-
function
|
|
31462
|
-
|
|
31608
|
+
function memoryDirectoryUri(config2, metadata) {
|
|
31609
|
+
const baseUri = `viking://user/${uriSegment(config2.user)}/memories`;
|
|
31610
|
+
const projectSegment = uriSegment(metadata.project ?? "general");
|
|
31611
|
+
switch (metadata.kind) {
|
|
31612
|
+
case "preference":
|
|
31613
|
+
return metadata.status === "active" ? `${baseUri}/preferences` : `${baseUri}/preferences/${uriSegment(metadata.status)}`;
|
|
31614
|
+
case "handoff":
|
|
31615
|
+
return `${baseUri}/handoffs/${uriSegment(metadata.status)}/${projectSegment}`;
|
|
31616
|
+
case "incident":
|
|
31617
|
+
return `${baseUri}/incidents/${uriSegment(metadata.status)}/${projectSegment}`;
|
|
31618
|
+
case "smoke":
|
|
31619
|
+
return `${baseUri}/smoke/${uriSegment(metadata.status)}`;
|
|
31620
|
+
case "durable":
|
|
31621
|
+
return metadata.status === "active" ? `${baseUri}/durable/projects/${projectSegment}` : `${baseUri}/durable/${uriSegment(metadata.status)}/${projectSegment}`;
|
|
31622
|
+
}
|
|
31623
|
+
}
|
|
31624
|
+
function shouldUseStableMemoryUri(metadata) {
|
|
31625
|
+
return metadata.status === "active" && metadata.topic !== void 0 && metadata.kind !== "smoke";
|
|
31626
|
+
}
|
|
31627
|
+
async function memoryWriteMode(ov, config2, memoryUri, metadata) {
|
|
31628
|
+
if (!shouldUseStableMemoryUri(metadata)) {
|
|
31629
|
+
return "create";
|
|
31630
|
+
}
|
|
31631
|
+
return await vikingResourceExists(ov, config2, memoryUri) ? "replace" : "create";
|
|
31632
|
+
}
|
|
31633
|
+
function vikingDirectoryChain(directoryUri) {
|
|
31634
|
+
const prefix = "viking://";
|
|
31635
|
+
if (!directoryUri.startsWith(prefix)) {
|
|
31636
|
+
return [directoryUri];
|
|
31637
|
+
}
|
|
31638
|
+
const parts = directoryUri.slice(prefix.length).split("/").filter(Boolean);
|
|
31639
|
+
const startIndex = parts[0] === "user" && parts.length > 2 ? 3 : 1;
|
|
31640
|
+
const chain = [];
|
|
31641
|
+
for (let index = startIndex; index <= parts.length; index += 1) {
|
|
31642
|
+
chain.push(`${prefix}${parts.slice(0, index).join("/")}`);
|
|
31643
|
+
}
|
|
31644
|
+
return chain;
|
|
31463
31645
|
}
|
|
31464
|
-
function
|
|
31465
|
-
|
|
31646
|
+
function exactMemoryScopes(config2, includeArchived) {
|
|
31647
|
+
const userBase = `viking://user/${uriSegment(config2.user)}/memories`;
|
|
31648
|
+
const scopes = [
|
|
31649
|
+
`${userBase}/preferences`,
|
|
31650
|
+
`${userBase}/durable/projects`,
|
|
31651
|
+
`${userBase}/handoffs/active`,
|
|
31652
|
+
`${userBase}/incidents/active`,
|
|
31653
|
+
`${userBase}/events`,
|
|
31654
|
+
`viking://agent/${uriSegment(config2.agentId)}/memories`
|
|
31655
|
+
];
|
|
31656
|
+
return includeArchived ? [...scopes, `${userBase}/durable/archived`, `${userBase}/handoffs/archived`, `${userBase}/incidents/archived`] : scopes;
|
|
31657
|
+
}
|
|
31658
|
+
function formatMemoryDocument(title, metadata, body) {
|
|
31659
|
+
const header = [
|
|
31660
|
+
title,
|
|
31661
|
+
`kind: ${metadata.kind}`,
|
|
31662
|
+
`status: ${metadata.status}`,
|
|
31663
|
+
metadata.project ? `project: ${metadata.project}` : void 0,
|
|
31664
|
+
metadata.topic ? `topic: ${metadata.topic}` : void 0,
|
|
31665
|
+
`source_agent_client: ${metadata.sourceAgentClient}`,
|
|
31666
|
+
`timestamp: ${metadata.timestamp}`,
|
|
31667
|
+
metadata.supersedes ? `supersedes: ${metadata.supersedes}` : void 0,
|
|
31668
|
+
metadata.archivedFrom ? `archived_from: ${metadata.archivedFrom}` : void 0
|
|
31669
|
+
].filter((line) => line !== void 0);
|
|
31670
|
+
return [...header, "", body.trim()].join("\n");
|
|
31671
|
+
}
|
|
31672
|
+
function normalizeOptionalMetadata(value) {
|
|
31673
|
+
const trimmed = value?.trim();
|
|
31674
|
+
return trimmed ? trimmed : void 0;
|
|
31466
31675
|
}
|
|
31467
31676
|
function uriSegment(value) {
|
|
31468
31677
|
const normalized = value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|