threadnote 0.5.0 → 0.6.1

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,3 +1,7 @@
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.
@@ -236,7 +240,11 @@ For deterministic moments where the agent shouldn't have to remember, Threadnote
236
240
  - **`PreCompact`** auto-stores a handoff snapshot for the current repo right before Claude compacts the conversation,
237
241
  so the next turn can recall it even if the agent forgot to write a handoff manually.
238
242
  - **`SessionStart`** preloads the latest threadnote handoff/feature memory for the current repo into the new session
239
- context, so the first turn already knows where you left off.
243
+ context, so the first turn already knows where you left off. The same hook also runs a daily-cached check against
244
+ the npm registry and prints a one-line `[threadnote] vX.Y.Z available; run threadnote update` banner when the
245
+ installed version is behind, so you stop missing releases. Set `THREADNOTE_AUTO_UPDATE=1` to skip the nag and have
246
+ the hook spawn `threadnote update --yes` in the background instead — the new version takes effect on the next
247
+ session.
240
248
 
241
249
  Install them per agent (Codex / Cursor / Copilot are no-ops today — Threadnote prints a clear explanation):
242
250
 
@@ -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(