@jefuriiij/synthra 0.14.1 → 0.15.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/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.15.0] — 2026-07-02
11
+
12
+ ### Added
13
+
14
+ - **Your remembered context now talks back.** Synthra's second brain was
15
+ write-only — decisions and gotchas went in via `context_remember` and never
16
+ resurfaced. Now they appear exactly where they're relevant:
17
+ - `graph_read` of a file (or a symbol in it) shows a `📌 Remembered for this
18
+ file` block with the entries linked to that file.
19
+ - `graph_continue` packs include `Remembered:` lines for entries matching the
20
+ query.
21
+ - **Memories know when they might be wrong.** Entries linked to files are now
22
+ *anchored* to those files' content hashes at capture time. When the code
23
+ changes afterwards (tracked live by auto-reindex), every surfacing of that
24
+ entry — graph_read, graph_continue, context_recall — carries
25
+ `⚠ possibly stale — <file> changed since stored`. Old entries without anchors
26
+ keep working and are never flagged; the shared context-store format is
27
+ unchanged (additive optional field).
28
+
29
+ ---
30
+
10
31
  ## [0.14.1] — 2026-07-02
11
32
 
12
33
  ### Added
package/dist/cli/index.js CHANGED
@@ -18,7 +18,7 @@ var init_package = __esm({
18
18
  "package.json"() {
19
19
  package_default = {
20
20
  name: "@jefuriiij/synthra",
21
- version: "0.14.1",
21
+ version: "0.15.0",
22
22
  publishConfig: {
23
23
  access: "public"
24
24
  },
@@ -3882,7 +3882,8 @@ async function rememberEntry(paths, input) {
3882
3882
  content: input.text,
3883
3883
  tags: input.tags ?? [],
3884
3884
  files: input.files ?? [],
3885
- date: (/* @__PURE__ */ new Date()).toISOString()
3885
+ date: (/* @__PURE__ */ new Date()).toISOString(),
3886
+ ...input.anchors && input.anchors.length > 0 ? { anchors: input.anchors } : {}
3886
3887
  };
3887
3888
  await appendEntry(active.paths.contextStore, entry);
3888
3889
  const entries = await readEntries(active.paths.contextStore);
@@ -4184,7 +4185,7 @@ var TOOLS = [
4184
4185
  files: {
4185
4186
  type: "array",
4186
4187
  items: { type: "string" },
4187
- description: "Optional project-relative file paths this entry relates to."
4188
+ description: "Optional project-relative file paths this entry relates to. Linked files also anchor the entry: recall flags it 'possibly stale' if they change, and graph_read of those files surfaces it automatically."
4188
4189
  }
4189
4190
  },
4190
4191
  required: ["text", "kind"]
@@ -4726,9 +4727,28 @@ async function graphContinue(args, ctx) {
4726
4727
  Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
4727
4728
  Reason: ${retrieval.reason}
4728
4729
  `;
4729
- return textContent(`${header}
4730
+ const remembered = matchRememberedFacts(query, retrieval.files, await safeRecallAll(ctx), ctx);
4731
+ return textContent(`${header}${remembered}
4730
4732
  ${packed.text}`);
4731
4733
  }
4734
+ function matchRememberedFacts(query, retrievedFiles, entries, ctx) {
4735
+ if (entries.length === 0) return "";
4736
+ const qTokens = new Set(tokenizeQuery(query));
4737
+ const retrievedPaths = new Set(retrievedFiles.map((f) => f.path));
4738
+ const scored = entries.map((e) => {
4739
+ let score2 = 0;
4740
+ for (const t of tokenizeQuery(`${e.content} ${e.tags.join(" ")}`)) {
4741
+ if (qTokens.has(t)) score2 += 1;
4742
+ }
4743
+ if (e.files.some((f) => retrievedPaths.has(f)) || e.anchors?.some((a) => retrievedPaths.has(a.path))) {
4744
+ score2 += 2;
4745
+ }
4746
+ return { e, score: score2 };
4747
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, PACK_FACTS_MAX);
4748
+ if (scored.length === 0) return "";
4749
+ return `${scored.map((x) => `Remembered: ${factLine(x.e, ctx.graph).slice(2)}`).join("\n")}
4750
+ `;
4751
+ }
4732
4752
  function resolveFileTarget(graph, filePath) {
4733
4753
  const files = graph.nodes.filter((n) => n.kind === "file");
4734
4754
  const exact = files.find((n) => n.path === filePath);
@@ -4821,6 +4841,43 @@ function buildTestsFooter(symbol, graph) {
4821
4841
  if (isLikelyEntry(symbol.file)) return "";
4822
4842
  return "Tests: none linked to this file.";
4823
4843
  }
4844
+ var FACTS_MAX = 3;
4845
+ var FACTS_CONTENT_MAX = 160;
4846
+ var PACK_FACTS_MAX = 2;
4847
+ function staleAnchorPaths(entry, graph) {
4848
+ if (!entry.anchors || entry.anchors.length === 0) return [];
4849
+ const hashByPath = /* @__PURE__ */ new Map();
4850
+ for (const n of graph.nodes) if (n.kind === "file") hashByPath.set(n.path, n.file_hash);
4851
+ return entry.anchors.filter((a) => hashByPath.get(a.path) !== a.hash).map((a) => a.path);
4852
+ }
4853
+ function factLine(entry, graph) {
4854
+ const content = entry.content.length > FACTS_CONTENT_MAX ? `${entry.content.slice(0, FACTS_CONTENT_MAX - 1)}\u2026` : entry.content;
4855
+ const date = entry.date ? ` (${entry.date.slice(0, 10)})` : "";
4856
+ const stale = staleAnchorPaths(entry, graph);
4857
+ const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale[0]} changed since stored` : "";
4858
+ return `- [${entry.type}] ${content}${date}${staleNote}`;
4859
+ }
4860
+ function entryLinksFile(entry, filePath) {
4861
+ if (entry.anchors?.some((a) => a.path === filePath)) return true;
4862
+ return entry.files.some((f) => f === filePath || filePath.endsWith(`/${f}`));
4863
+ }
4864
+ function buildFactsFooter(filePath, entries, graph) {
4865
+ const linked = entries.filter((e) => entryLinksFile(e, filePath));
4866
+ if (linked.length === 0) return "";
4867
+ const newestFirst = linked.slice().reverse();
4868
+ const shown = newestFirst.slice(0, FACTS_MAX);
4869
+ const omitted = newestFirst.length - shown.length;
4870
+ const lines = ["\u{1F4CC} Remembered for this file:", ...shown.map((e) => factLine(e, graph))];
4871
+ if (omitted > 0) lines.push(`\u2026+${omitted} more \u2014 mcp__synthra__context_recall()`);
4872
+ return lines.join("\n");
4873
+ }
4874
+ async function safeRecallAll(ctx) {
4875
+ try {
4876
+ return (await recallEntries(ctx.paths, {})).entries;
4877
+ } catch {
4878
+ return [];
4879
+ }
4880
+ }
4824
4881
  async function graphRead(args, ctx) {
4825
4882
  const target = typeof args?.target === "string" ? args.target : "";
4826
4883
  if (!target) return errorContent("graph_read: 'target' (string) is required");
@@ -4839,10 +4896,15 @@ async function graphRead(args, ctx) {
4839
4896
  }
4840
4897
  const fileNode = resolved.node;
4841
4898
  await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
4899
+ const facts = buildFactsFooter(fileNode.path, await safeRecallAll(ctx), ctx.graph);
4900
+ const factsBlock = facts ? `
4901
+
4902
+ ---
4903
+ ${facts}` : "";
4842
4904
  if (!symbolName) {
4843
4905
  return textContent(`# ${fileNode.path}
4844
4906
 
4845
- ${fileNode.content}`);
4907
+ ${fileNode.content}${factsBlock}`);
4846
4908
  }
4847
4909
  const cleanSym = symbolName.trim();
4848
4910
  const symbol = ctx.graph.nodes.find(
@@ -4872,7 +4934,7 @@ ${tests}` : "";
4872
4934
  return textContent(
4873
4935
  `# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
4874
4936
 
4875
- ${body}${depsBlock}${testsBlock}${editHint}`
4937
+ ${body}${depsBlock}${testsBlock}${factsBlock}${editHint}`
4876
4938
  );
4877
4939
  }
4878
4940
  var editedFiles = /* @__PURE__ */ new Set();
@@ -4907,16 +4969,26 @@ async function contextRemember(args, ctx) {
4907
4969
  }
4908
4970
  const tags = Array.isArray(args?.tags) ? args.tags.filter((t) => typeof t === "string") : [];
4909
4971
  const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
4972
+ const anchors = [];
4973
+ for (const f of files) {
4974
+ const resolved = resolveFileTarget(ctx.graph, f);
4975
+ if ("node" in resolved) {
4976
+ anchors.push({ path: resolved.node.path, hash: resolved.node.file_hash });
4977
+ }
4978
+ }
4910
4979
  const result = await rememberEntry(ctx.paths, {
4911
4980
  text,
4912
4981
  kind: kindRaw,
4913
4982
  tags,
4914
- files
4983
+ files,
4984
+ anchors
4915
4985
  });
4986
+ const anchorNote = anchors.length ? `
4987
+ Anchored to ${anchors.length} file(s) \u2014 recall will flag this entry if they change.` : "";
4916
4988
  return textContent(
4917
4989
  `Remembered ${result.entry.type} on branch '${result.branch}'.
4918
4990
  Stored: ${result.storePath}
4919
- CONTEXT.md refreshed: ${result.contextMdPath}`
4991
+ CONTEXT.md refreshed: ${result.contextMdPath}${anchorNote}`
4920
4992
  );
4921
4993
  }
4922
4994
  var DEFAULT_RECENT_WINDOW_MS = 60 * 60 * 1e3;
@@ -4951,7 +5023,9 @@ async function contextRecall(args, ctx) {
4951
5023
  const lines = [`# Context entries \u2014 branch: ${result.branch}`, ""];
4952
5024
  for (const e of result.entries) {
4953
5025
  const tags = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
4954
- lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}`);
5026
+ const stale = staleAnchorPaths(e, ctx.graph);
5027
+ const staleNote = stale.length ? ` \u26A0 possibly stale \u2014 ${stale.join(", ")} changed since stored` : "";
5028
+ lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}${staleNote}`);
4955
5029
  if (e.files.length) lines.push(` files: ${e.files.join(", ")}`);
4956
5030
  }
4957
5031
  return textContent(lines.join("\n"));