threadnote 0.4.1 → 0.6.0
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 +39 -0
- package/dist/mcp_server.cjs +103 -31
- package/dist/threadnote.cjs +522 -193
- package/docs/.nojekyll +0 -0
- package/docs/index.html +1543 -0
- package/docs/share.md +26 -5
- package/docs/threadnote-logo-inverted.svg +1 -0
- package/docs/threadnote-logo.svg +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./docs/threadnote-logo.svg" alt="Threadnote logo" width="200">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# Threadnote
|
|
2
6
|
|
|
3
7
|
`threadnote` is a safe local workflow for using [OpenViking](https://openviking.ai/) as shared, agent-neutral context for development work.
|
|
4
8
|
It is intentionally scoped to curated docs, memories, skills, and handoffs. It is not a source-navigation replacement,
|
|
5
9
|
and it does not index whole repositories by default.
|
|
6
10
|
|
|
11
|
+
> **Walkthrough deck:** https://kashkovsky.github.io/threadnote/
|
|
12
|
+
|
|
7
13
|
## Real-World Uses
|
|
8
14
|
|
|
9
15
|
**Want to continue work in a fresh agent session?**
|
|
@@ -222,6 +228,39 @@ claude mcp add threadnote -- threadnote-mcp-server
|
|
|
222
228
|
Cursor uses the equivalent entry in `~/.cursor/mcp.json`.
|
|
223
229
|
Copilot uses the VS Code MCP `servers` entry in the user-profile `mcp.json`.
|
|
224
230
|
|
|
231
|
+
### Agent hooks (optional, opt-in)
|
|
232
|
+
|
|
233
|
+
The instruction files (`~/.codex/AGENTS.md`, `~/.claude/CLAUDE.md`, etc.) are the **cross-agent guidance floor** — they
|
|
234
|
+
ask agents to recall context, store handoffs, and so on, and they work for every supported agent. They rely on the
|
|
235
|
+
agent to comply.
|
|
236
|
+
|
|
237
|
+
For deterministic moments where the agent shouldn't have to remember, Threadnote can also install agent-side
|
|
238
|
+
**hooks** — currently for Claude Code (the only supported agent that exposes a hook surface today):
|
|
239
|
+
|
|
240
|
+
- **`PreCompact`** auto-stores a handoff snapshot for the current repo right before Claude compacts the conversation,
|
|
241
|
+
so the next turn can recall it even if the agent forgot to write a handoff manually.
|
|
242
|
+
- **`SessionStart`** preloads the latest threadnote handoff/feature memory for the current repo into the new session
|
|
243
|
+
context, so the first turn already knows where you left off.
|
|
244
|
+
|
|
245
|
+
Install them per agent (Codex / Cursor / Copilot are no-ops today — Threadnote prints a clear explanation):
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
threadnote install-hooks claude --dry-run # preview the change
|
|
249
|
+
threadnote install-hooks claude --apply # add managed entries to ~/.claude/settings.json
|
|
250
|
+
threadnote install-hooks claude --remove --apply # take them out again
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Or opt in at install time and let Threadnote drive every supported agent in one shot:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
threadnote install --with-hooks
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Managed entries are tagged with `"_threadnote": "managed"` so `threadnote repair` and `threadnote uninstall` can find
|
|
260
|
+
and rewrite/remove only those entries without touching any of your own hooks. Hooks complement, not replace, the
|
|
261
|
+
instruction files: the soft guidance covers semantic decisions ("is this memory durable?", "is this work meaningful
|
|
262
|
+
enough to publish?"); hooks cover deterministic moments the agent shouldn't be trusted to remember.
|
|
263
|
+
|
|
225
264
|
If a future OpenViking build exposes a healthy native endpoint, install it explicitly:
|
|
226
265
|
|
|
227
266
|
```bash
|
package/dist/mcp_server.cjs
CHANGED
|
@@ -33736,6 +33736,8 @@ var TEAMS_FILE_VERSION = 1;
|
|
|
33736
33736
|
var SHARED_SEGMENT = "shared";
|
|
33737
33737
|
var DEFAULT_GIT_REMOTE_NAME = "origin";
|
|
33738
33738
|
var SCRUBBER_PATTERNS = [
|
|
33739
|
+
// Credentials: never redactable. Blocking is the only safe response —
|
|
33740
|
+
// automated redaction risks false negatives that leave material in git.
|
|
33739
33741
|
{ name: "private key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
33740
33742
|
{ name: "API key (sk-...)", regex: /\bsk-[A-Za-z0-9_-]{16,}/ },
|
|
33741
33743
|
{ name: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{16,}/ },
|
|
@@ -33748,8 +33750,35 @@ var SCRUBBER_PATTERNS = [
|
|
|
33748
33750
|
{ name: "AWS access key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
33749
33751
|
// Slack tokens: xoxa/xoxb/xoxc (configuration)/xoxd (legacy user cookie)/
|
|
33750
33752
|
// xoxe (refresh)/xoxp/xoxr/xoxs, with optional -N- segment for the workspace tier.
|
|
33751
|
-
{ name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i }
|
|
33753
|
+
{ name: "Slack token", regex: /\bxox[abcdeprs](?:-\d-)?[A-Za-z0-9._-]{10,}/i },
|
|
33754
|
+
// Soft leaks: block by default (so the agent sees them and decides), but
|
|
33755
|
+
// allow opt-in redaction so curated memories with incidental matches can
|
|
33756
|
+
// ship without a manual rewrite. Local home paths are the recurring
|
|
33757
|
+
// real-world leak; the regexes greedily consume the whole path segment
|
|
33758
|
+
// (including subdirectories) up to whitespace or common closing punctuation
|
|
33759
|
+
// so redaction collapses an entire path to a single placeholder rather than
|
|
33760
|
+
// leaving the subpath visible.
|
|
33761
|
+
{ name: "macOS home path", placeholder: "<local-path>", regex: /\/Users\/[^\s)>"'`,]+/ },
|
|
33762
|
+
{ name: "linux home path", placeholder: "<local-path>", regex: /\b\/home\/[^\s)>"'`,]+/ }
|
|
33752
33763
|
];
|
|
33764
|
+
function applyScrubber(content, { redact }) {
|
|
33765
|
+
let cleaned = content;
|
|
33766
|
+
const redactions = [];
|
|
33767
|
+
for (const pattern of SCRUBBER_PATTERNS) {
|
|
33768
|
+
if (!pattern.regex.test(cleaned)) {
|
|
33769
|
+
continue;
|
|
33770
|
+
}
|
|
33771
|
+
if (!pattern.placeholder || !redact) {
|
|
33772
|
+
return { blocker: pattern.name, cleaned: content, redactions: [] };
|
|
33773
|
+
}
|
|
33774
|
+
const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`;
|
|
33775
|
+
const globalRegex = new RegExp(pattern.regex.source, flags);
|
|
33776
|
+
const matches = cleaned.match(globalRegex) ?? [];
|
|
33777
|
+
cleaned = cleaned.replace(globalRegex, pattern.placeholder);
|
|
33778
|
+
redactions.push({ count: matches.length, name: pattern.name });
|
|
33779
|
+
}
|
|
33780
|
+
return { cleaned, redactions };
|
|
33781
|
+
}
|
|
33753
33782
|
function normalizeTeamName(input) {
|
|
33754
33783
|
const candidate = (input ?? "default").trim();
|
|
33755
33784
|
if (!candidate) {
|
|
@@ -33852,8 +33881,26 @@ function vikingUriToWorktreeRelative(config2, uri, team) {
|
|
|
33852
33881
|
}
|
|
33853
33882
|
return uri.slice(prefix.length);
|
|
33854
33883
|
}
|
|
33855
|
-
function
|
|
33856
|
-
|
|
33884
|
+
function stripPersonalProvenance(content) {
|
|
33885
|
+
const lines = content.split("\n");
|
|
33886
|
+
let headerEnd = lines.length;
|
|
33887
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
33888
|
+
if (lines[index].trim() === "") {
|
|
33889
|
+
headerEnd = index;
|
|
33890
|
+
break;
|
|
33891
|
+
}
|
|
33892
|
+
}
|
|
33893
|
+
const cleaned = [];
|
|
33894
|
+
for (let index = 0; index < headerEnd; index += 1) {
|
|
33895
|
+
if (/^(?:supersedes|archived_from):\s/.test(lines[index])) {
|
|
33896
|
+
continue;
|
|
33897
|
+
}
|
|
33898
|
+
cleaned.push(lines[index]);
|
|
33899
|
+
}
|
|
33900
|
+
for (let index = headerEnd; index < lines.length; index += 1) {
|
|
33901
|
+
cleaned.push(lines[index]);
|
|
33902
|
+
}
|
|
33903
|
+
return cleaned.join("\n");
|
|
33857
33904
|
}
|
|
33858
33905
|
async function ensureSharedDirectoryChain(config2, ov, memoryUri, dryRun) {
|
|
33859
33906
|
const directoryUri = parentUri(memoryUri);
|
|
@@ -34230,15 +34277,19 @@ function registerTools(server, config2) {
|
|
|
34230
34277
|
"share_publish",
|
|
34231
34278
|
{
|
|
34232
34279
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
34233
|
-
description: "Publish a personal memory into a team's shared memories git repo. Reads the memory, refuses publish if it matches secret patterns, copies it into the shared subtree, removes the personal original, and commits/pushes. Default team is used unless team is provided.",
|
|
34280
|
+
description: "Publish a personal memory into a team's shared memories git repo. Reads the memory, strips local-only provenance frontmatter (supersedes:, archived_from:), refuses publish if it matches secret patterns, copies it into the shared subtree, removes the personal original, and commits/pushes. Default team is used unless team is provided. Pass preview=true to return the would-be-published bytes without writing or committing.",
|
|
34234
34281
|
inputSchema: {
|
|
34235
34282
|
message: external_exports.string().optional().describe('Commit message override; defaults to "share: publish <path>"'),
|
|
34283
|
+
preview: external_exports.boolean().optional().describe(
|
|
34284
|
+
"Return the bytes that would land in the shared git repo (after frontmatter strip and redaction) without writing or committing. Use this to inspect the body before publishing."
|
|
34285
|
+
),
|
|
34236
34286
|
push: external_exports.boolean().optional().describe("Push to remote after committing; defaults to true"),
|
|
34287
|
+
redact: external_exports.boolean().optional().describe("Replace soft-leak matches (local paths) with placeholders and continue; credentials still block."),
|
|
34237
34288
|
team: external_exports.string().optional().describe("Team name; defaults to the configured default team"),
|
|
34238
34289
|
uri: external_exports.string().optional().describe("Required viking:// memory URI to publish")
|
|
34239
34290
|
}
|
|
34240
34291
|
},
|
|
34241
|
-
async ({ message, push, team, uri }) => {
|
|
34292
|
+
async ({ message, preview, push, redact, team, uri }) => {
|
|
34242
34293
|
const checkedUri = requiredVikingUri(
|
|
34243
34294
|
uri,
|
|
34244
34295
|
"share_publish",
|
|
@@ -34247,7 +34298,7 @@ function registerTools(server, config2) {
|
|
|
34247
34298
|
if (!checkedUri.ok) {
|
|
34248
34299
|
return checkedUri.error;
|
|
34249
34300
|
}
|
|
34250
|
-
return runSharePublishTool(config2, checkedUri.value, { message, push, team });
|
|
34301
|
+
return runSharePublishTool(config2, checkedUri.value, { message, preview, push, redact, team });
|
|
34251
34302
|
}
|
|
34252
34303
|
);
|
|
34253
34304
|
}
|
|
@@ -34408,16 +34459,14 @@ function registerStoreTool(server, config2, name, description) {
|
|
|
34408
34459
|
project: normalizeOptionalMetadata(project),
|
|
34409
34460
|
sourceAgentClient: sourceAgentClient ?? "mcp",
|
|
34410
34461
|
status: status ?? "active",
|
|
34411
|
-
supersedes: checkedReplaceUri.value,
|
|
34412
34462
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
34413
34463
|
topic: normalizeOptionalMetadata(topic)
|
|
34414
34464
|
};
|
|
34415
|
-
return writeDurableMemory(
|
|
34416
|
-
|
|
34417
|
-
formatMemoryDocument("MEMORY", metadata, checkedText.value),
|
|
34465
|
+
return writeDurableMemory(config2, {
|
|
34466
|
+
bodyText: checkedText.value,
|
|
34418
34467
|
metadata,
|
|
34419
|
-
checkedReplaceUri.value
|
|
34420
|
-
);
|
|
34468
|
+
replaceUri: checkedReplaceUri.value
|
|
34469
|
+
});
|
|
34421
34470
|
}
|
|
34422
34471
|
);
|
|
34423
34472
|
}
|
|
@@ -34458,12 +34507,10 @@ function registerArchiveTool(server, config2, name, description) {
|
|
|
34458
34507
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
34459
34508
|
topic: normalizeOptionalMetadata(topic)
|
|
34460
34509
|
};
|
|
34461
|
-
const archiveResult = await writeDurableMemory(
|
|
34462
|
-
|
|
34463
|
-
|
|
34464
|
-
|
|
34465
|
-
void 0
|
|
34466
|
-
);
|
|
34510
|
+
const archiveResult = await writeDurableMemory(config2, {
|
|
34511
|
+
bodyText: ["Archived original Threadnote memory.", "", original].join("\n"),
|
|
34512
|
+
metadata
|
|
34513
|
+
});
|
|
34467
34514
|
if (archiveResult.isError === true) {
|
|
34468
34515
|
return archiveResult;
|
|
34469
34516
|
}
|
|
@@ -34486,13 +34533,18 @@ Archive stored, but original memory is still processing. Retry later with forget
|
|
|
34486
34533
|
}
|
|
34487
34534
|
);
|
|
34488
34535
|
}
|
|
34489
|
-
async function writeDurableMemory(config2,
|
|
34536
|
+
async function writeDurableMemory(config2, params) {
|
|
34490
34537
|
try {
|
|
34491
34538
|
const ov = await requiredOpenVikingCli();
|
|
34492
|
-
const directoryUri = memoryDirectoryUri(config2, metadata);
|
|
34539
|
+
const directoryUri = memoryDirectoryUri(config2, params.metadata);
|
|
34493
34540
|
await ensureMemoryDirectory(ov, config2, directoryUri);
|
|
34494
|
-
const
|
|
34495
|
-
const
|
|
34541
|
+
const candidateMetadata = { ...params.metadata, supersedes: params.replaceUri };
|
|
34542
|
+
const candidateMemory = formatMemoryDocument("MEMORY", candidateMetadata, params.bodyText);
|
|
34543
|
+
const memoryUri = memoryUriFor(config2, candidateMemory, candidateMetadata);
|
|
34544
|
+
const isInPlaceUpdate = params.replaceUri !== void 0 && params.replaceUri === memoryUri;
|
|
34545
|
+
const finalMetadata = isInPlaceUpdate ? { ...params.metadata, supersedes: void 0 } : candidateMetadata;
|
|
34546
|
+
const memory = isInPlaceUpdate ? formatMemoryDocument("MEMORY", finalMetadata, params.bodyText) : candidateMemory;
|
|
34547
|
+
const writeMode = await memoryWriteMode(ov, config2, memoryUri, finalMetadata);
|
|
34496
34548
|
const result = await runOpenVikingWriteWithRetry(
|
|
34497
34549
|
ov,
|
|
34498
34550
|
config2,
|
|
@@ -34510,12 +34562,12 @@ async function writeDurableMemory(config2, memory, metadata, replaceUri) {
|
|
|
34510
34562
|
])
|
|
34511
34563
|
);
|
|
34512
34564
|
const messages = [`Stored memory: ${memoryUri}`];
|
|
34513
|
-
if (replaceUri &&
|
|
34514
|
-
const removedReplacedMemory = await removeVikingResourceWithRetry(ov, config2, replaceUri);
|
|
34565
|
+
if (params.replaceUri && !isInPlaceUpdate) {
|
|
34566
|
+
const removedReplacedMemory = await removeVikingResourceWithRetry(ov, config2, params.replaceUri);
|
|
34515
34567
|
messages.push(
|
|
34516
|
-
removedReplacedMemory ? `Forgot replaced memory: ${replaceUri}` : `Replacement stored, but superseded memory is still processing. Retry later with forget: ${replaceUri}`
|
|
34568
|
+
removedReplacedMemory ? `Forgot replaced memory: ${params.replaceUri}` : `Replacement stored, but superseded memory is still processing. Retry later with forget: ${params.replaceUri}`
|
|
34517
34569
|
);
|
|
34518
|
-
} else if (
|
|
34570
|
+
} else if (isInPlaceUpdate) {
|
|
34519
34571
|
messages.push(`Updated existing memory in place: ${memoryUri}`);
|
|
34520
34572
|
}
|
|
34521
34573
|
const text = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
|
|
@@ -34732,14 +34784,31 @@ async function runSharePublishTool(config2, sourceUri, options) {
|
|
|
34732
34784
|
isError: true
|
|
34733
34785
|
};
|
|
34734
34786
|
}
|
|
34735
|
-
const
|
|
34736
|
-
const
|
|
34737
|
-
|
|
34787
|
+
const stripped = stripPersonalProvenance(readResult.stdout);
|
|
34788
|
+
const scrub = applyScrubber(stripped, { redact: options.redact === true });
|
|
34789
|
+
const targetUri = sharedUriFor(config2, sourceUri, resolved.name);
|
|
34790
|
+
if (options.preview === true) {
|
|
34791
|
+
const previewLines = [`PREVIEW source: ${sourceUri}`, `PREVIEW destination: ${targetUri}`];
|
|
34792
|
+
if (scrub.blocker) {
|
|
34793
|
+
previewLines.push(
|
|
34794
|
+
`PREVIEW BLOCKED: ${scrub.blocker}. Strip the sensitive value or pass redact=true for soft-leak patterns.`
|
|
34795
|
+
);
|
|
34796
|
+
return { content: [{ type: "text", text: previewLines.join("\n") }] };
|
|
34797
|
+
}
|
|
34798
|
+
for (const redaction of scrub.redactions) {
|
|
34799
|
+
previewLines.push(`PREVIEW redact: ${redaction.count}\xD7 ${redaction.name}`);
|
|
34800
|
+
}
|
|
34801
|
+
previewLines.push("-----BEGIN PREVIEW-----");
|
|
34802
|
+
previewLines.push(scrub.cleaned);
|
|
34803
|
+
previewLines.push("-----END PREVIEW-----");
|
|
34804
|
+
return { content: [{ type: "text", text: previewLines.join("\n") }] };
|
|
34805
|
+
}
|
|
34806
|
+
if (scrub.blocker) {
|
|
34738
34807
|
return argumentError(
|
|
34739
|
-
`Refusing to publish ${sourceUri}: possible ${blocker}. Strip the sensitive value
|
|
34808
|
+
`Refusing to publish ${sourceUri}: possible ${scrub.blocker}. Strip the sensitive value or pass redact=true for soft-leak patterns.`
|
|
34740
34809
|
);
|
|
34741
34810
|
}
|
|
34742
|
-
const
|
|
34811
|
+
const content = scrub.cleaned;
|
|
34743
34812
|
if (await vikingResourceExists(ov, config2, targetUri)) {
|
|
34744
34813
|
return argumentError(
|
|
34745
34814
|
`Refusing to publish: ${targetUri} already exists in the shared namespace. Inspect it via threadnote read; if it should be replaced, forget the existing shared copy first.`
|
|
@@ -34766,6 +34835,9 @@ async function runSharePublishTool(config2, sourceUri, options) {
|
|
|
34766
34835
|
};
|
|
34767
34836
|
}
|
|
34768
34837
|
const messages = [`Published ${sourceUri} -> ${targetUri}`];
|
|
34838
|
+
for (const redaction of scrub.redactions) {
|
|
34839
|
+
messages.push(`Redacted ${redaction.count}\xD7 ${redaction.name} before publish.`);
|
|
34840
|
+
}
|
|
34769
34841
|
const relativePath = vikingUriToWorktreeRelative(config2, targetUri, resolved.name);
|
|
34770
34842
|
const commitMessage = options.message ?? `share: publish ${relativePath}`;
|
|
34771
34843
|
const gitMessages = await gitPublishWorkflow(
|