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 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
@@ -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 scrubberBlocker(content) {
33856
- return SCRUBBER_PATTERNS.find((pattern) => pattern.regex.test(content))?.name;
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
- config2,
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
- config2,
34463
- formatMemoryDocument("MEMORY", metadata, ["Archived original Threadnote memory.", "", original].join("\n")),
34464
- metadata,
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, memory, metadata, replaceUri) {
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 memoryUri = memoryUriFor(config2, memory, metadata);
34495
- const writeMode = await memoryWriteMode(ov, config2, memoryUri, metadata);
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 && replaceUri !== memoryUri) {
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 (replaceUri === memoryUri) {
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 content = readResult.stdout;
34736
- const blocker = scrubberBlocker(content);
34737
- if (blocker) {
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, then retry.`
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 targetUri = sharedUriFor(config2, sourceUri, resolved.name);
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(