@jefuriiij/synthra 0.1.25 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  // src/server/http.ts
2
2
  import { serve } from "@hono/node-server";
3
3
  import { Hono } from "hono";
4
- import { writeFile as writeFile6 } from "fs/promises";
4
+ import { writeFile as writeFile8 } from "fs/promises";
5
5
 
6
6
  // src/activity/activity-log.ts
7
7
  import { appendFile, mkdir } from "fs/promises";
@@ -488,7 +488,20 @@ function extractKeywords(content, _ext) {
488
488
  }
489
489
 
490
490
  // src/scanner/extract.ts
491
- var RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".svelte", ".vue", ".dart", ".html", ".hubl"];
491
+ var RESOLVE_EXTS = [
492
+ ".ts",
493
+ ".tsx",
494
+ ".js",
495
+ ".jsx",
496
+ ".mjs",
497
+ ".cjs",
498
+ ".py",
499
+ ".svelte",
500
+ ".vue",
501
+ ".dart",
502
+ ".html",
503
+ ".hubl"
504
+ ];
492
505
  var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx", "__init__.py"];
493
506
  function fileId(relPath) {
494
507
  return `file:${relPath}`;
@@ -1597,6 +1610,8 @@ function resolvePaths(projectRoot) {
1597
1610
  tokenLog: join5(graphDir, "token_log.jsonl"),
1598
1611
  gateLog: join5(graphDir, "gate_log.jsonl"),
1599
1612
  toolLog: join5(graphDir, "tool_log.jsonl"),
1613
+ accessLog: join5(graphDir, "access_log.jsonl"),
1614
+ learnStore: join5(graphDir, "learn_store.json"),
1600
1615
  mcpPort: join5(graphDir, "mcp_port"),
1601
1616
  mcpServerLog: join5(graphDir, "mcp_server.log"),
1602
1617
  mcpServerErrLog: join5(graphDir, "mcp_server.err.log"),
@@ -1618,7 +1633,7 @@ import { basename as basename2 } from "path";
1618
1633
  // src/hooks/claude-md.ts
1619
1634
  import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
1620
1635
  import { basename, dirname as dirname4 } from "path";
1621
- var POLICY_VERSION = 5;
1636
+ var POLICY_VERSION = 6;
1622
1637
  var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
1623
1638
  var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
1624
1639
  var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
@@ -1709,6 +1724,17 @@ function policyBlock() {
1709
1724
  "- Don't call `graph_continue` more than once per turn.",
1710
1725
  "- Don't read whole files when a symbol-level read would suffice.",
1711
1726
  "",
1727
+ "### Resuming a session",
1728
+ "",
1729
+ 'At session start the primer may begin with a **"Since you were last here"**',
1730
+ "digest \u2014 recent commits, files touched, open next-steps, and recent",
1731
+ "decisions carried over from the previous session. **Trust it.** It is the",
1732
+ "cheapest possible orientation: do NOT re-run `graph_continue` or Grep just",
1733
+ 'to rediscover "what were we doing / what changed" \u2014 that work is already',
1734
+ 'done. For the concrete next steps, `context_recall({kind:"next"})` returns',
1735
+ "them verbatim. Only reach for fresh retrieval when the task moves beyond",
1736
+ "what the digest covers.",
1737
+ "",
1712
1738
  "### Session-end resume note",
1713
1739
  "",
1714
1740
  `When the user signals they're done (e.g. "bye", "wrap up", "done"),`,
@@ -1887,7 +1913,9 @@ async function scanProject(projectRootRaw, opts = {}) {
1887
1913
  if (boot.gitignoreUpdated) log.info(" updated .gitignore");
1888
1914
  if (boot.claudeMdCreated) {
1889
1915
  log.info(" created CLAUDE.md \u2014 onboarding skeleton for the agent");
1890
- log.info(" \u21B3 fill in Build / Conventions / Decisions (or run /init in Claude to auto-draft)");
1916
+ log.info(
1917
+ " \u21B3 fill in Build / Conventions / Decisions (or run /init in Claude to auto-draft)"
1918
+ );
1891
1919
  } else if (boot.claudeMdUpdated) {
1892
1920
  log.info(" updated CLAUDE.md");
1893
1921
  }
@@ -1932,11 +1960,192 @@ async function scanProject(projectRootRaw, opts = {}) {
1932
1960
  };
1933
1961
  }
1934
1962
 
1963
+ // src/learn/store.ts
1964
+ import { appendFile as appendFile2, mkdir as mkdir4, readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
1965
+ import { dirname as dirname5 } from "path";
1966
+
1967
+ // src/learn/usage.ts
1968
+ var LEARN_SCHEMA_VERSION = 1;
1969
+ var DAY_MS = 24 * 60 * 60 * 1e3;
1970
+ function halfLifeMs() {
1971
+ const env = Number(process.env.SYN_LEARN_HALFLIFE_DAYS);
1972
+ const days = Number.isFinite(env) && env > 0 ? env : 7;
1973
+ return days * DAY_MS;
1974
+ }
1975
+ function weightFor(source) {
1976
+ switch (source) {
1977
+ case "register_edit":
1978
+ return 2;
1979
+ case "read":
1980
+ return 1;
1981
+ default:
1982
+ return 0;
1983
+ }
1984
+ }
1985
+ function emptyStore() {
1986
+ return {
1987
+ schema_version: LEARN_SCHEMA_VERSION,
1988
+ asOf: (/* @__PURE__ */ new Date(0)).toISOString(),
1989
+ files: {}
1990
+ };
1991
+ }
1992
+ function decayFactor(fromTs, toMs, hl) {
1993
+ const fromMs = Date.parse(fromTs);
1994
+ if (!Number.isFinite(fromMs)) return 1;
1995
+ const dt = toMs - fromMs;
1996
+ if (dt <= 0) return 1;
1997
+ return Math.exp(-(Math.LN2 / hl) * dt);
1998
+ }
1999
+ function foldEvent(store, ev) {
2000
+ const w = weightFor(ev.source);
2001
+ if (w <= 0 || !ev.path) return store;
2002
+ const tMs = Date.parse(ev.ts);
2003
+ if (!Number.isFinite(tMs)) return store;
2004
+ const hl = halfLifeMs();
2005
+ const prev = store.files[ev.path];
2006
+ if (prev) {
2007
+ const decayed = prev.decayed * decayFactor(prev.lastTs, tMs, hl) + w;
2008
+ store.files[ev.path] = { count: prev.count + 1, decayed, lastTs: ev.ts };
2009
+ } else {
2010
+ store.files[ev.path] = { count: 1, decayed: w, lastTs: ev.ts };
2011
+ }
2012
+ return store;
2013
+ }
2014
+ function effectiveScores(store, nowMs) {
2015
+ const hl = halfLifeMs();
2016
+ const out = /* @__PURE__ */ new Map();
2017
+ for (const [path, stat3] of Object.entries(store.files)) {
2018
+ const eff = stat3.decayed * decayFactor(stat3.lastTs, nowMs, hl);
2019
+ if (eff > 0.01) out.set(path, eff);
2020
+ }
2021
+ return out;
2022
+ }
2023
+ function recomputeFromLog(events) {
2024
+ const store = emptyStore();
2025
+ for (const ev of events) foldEvent(store, ev);
2026
+ return store;
2027
+ }
2028
+
2029
+ // src/learn/store.ts
2030
+ async function readLearnStore(path) {
2031
+ try {
2032
+ const raw = await readFile8(path, "utf8");
2033
+ const parsed = JSON.parse(raw);
2034
+ if (parsed.schema_version !== LEARN_SCHEMA_VERSION || typeof parsed.files !== "object" || parsed.files === null) {
2035
+ return emptyStore();
2036
+ }
2037
+ return {
2038
+ schema_version: LEARN_SCHEMA_VERSION,
2039
+ asOf: typeof parsed.asOf === "string" ? parsed.asOf : emptyStore().asOf,
2040
+ files: parsed.files
2041
+ };
2042
+ } catch {
2043
+ return emptyStore();
2044
+ }
2045
+ }
2046
+ async function writeLearnStore(path, store) {
2047
+ try {
2048
+ await mkdir4(dirname5(path), { recursive: true });
2049
+ await writeFile4(path, JSON.stringify(store, null, 2) + "\n", "utf8");
2050
+ } catch {
2051
+ }
2052
+ }
2053
+ async function readAccessLog(path) {
2054
+ try {
2055
+ const raw = await readFile8(path, "utf8");
2056
+ const out = [];
2057
+ for (const line of raw.split("\n")) {
2058
+ const t = line.trim();
2059
+ if (!t) continue;
2060
+ try {
2061
+ const ev = JSON.parse(t);
2062
+ if (ev && typeof ev.ts === "string" && typeof ev.path === "string" && typeof ev.source === "string") {
2063
+ out.push(ev);
2064
+ }
2065
+ } catch {
2066
+ }
2067
+ }
2068
+ return out;
2069
+ } catch {
2070
+ return [];
2071
+ }
2072
+ }
2073
+ async function appendAccess(path, ev) {
2074
+ try {
2075
+ await mkdir4(dirname5(path), { recursive: true });
2076
+ await appendFile2(path, JSON.stringify(ev) + "\n", "utf8");
2077
+ } catch {
2078
+ }
2079
+ }
2080
+
2081
+ // src/learn/runtime.ts
2082
+ var PERSIST_DEBOUNCE_MS = 2e3;
2083
+ var LearnRuntime = class _LearnRuntime {
2084
+ constructor(accessLogPath, storePath, store) {
2085
+ this.accessLogPath = accessLogPath;
2086
+ this.storePath = storePath;
2087
+ this.store = store;
2088
+ }
2089
+ accessLogPath;
2090
+ storePath;
2091
+ store;
2092
+ dirty = false;
2093
+ timer = null;
2094
+ /** Load the aggregate from disk; if it's empty but a raw log exists, replay it
2095
+ * (the log is the source of truth). Always succeeds — falls back to empty. */
2096
+ static async load(accessLogPath, storePath) {
2097
+ let store = await readLearnStore(storePath);
2098
+ if (Object.keys(store.files).length === 0) {
2099
+ const events = await readAccessLog(accessLogPath);
2100
+ if (events.length > 0) store = recomputeFromLog(events);
2101
+ }
2102
+ return new _LearnRuntime(accessLogPath, storePath, store);
2103
+ }
2104
+ /** Record an access: append to the durable log + fold into the in-memory
2105
+ * aggregate. Best-effort — never throws into a tool call. */
2106
+ async record(ev) {
2107
+ await appendAccess(this.accessLogPath, ev);
2108
+ foldEvent(this.store, ev);
2109
+ this.schedulePersist();
2110
+ }
2111
+ /** Decayed path→weight map for the ranker, as of now. */
2112
+ effectiveScores(nowMs = Date.now()) {
2113
+ return effectiveScores(this.store, nowMs);
2114
+ }
2115
+ schedulePersist() {
2116
+ this.dirty = true;
2117
+ if (this.timer) return;
2118
+ this.timer = setTimeout(() => {
2119
+ this.timer = null;
2120
+ void this.flush();
2121
+ }, PERSIST_DEBOUNCE_MS);
2122
+ this.timer.unref?.();
2123
+ }
2124
+ /** Persist the aggregate if it changed since the last write. Called on the
2125
+ * debounce and on server shutdown. */
2126
+ async flush() {
2127
+ if (this.timer) {
2128
+ clearTimeout(this.timer);
2129
+ this.timer = null;
2130
+ }
2131
+ if (!this.dirty) return;
2132
+ this.dirty = false;
2133
+ this.store.asOf = (/* @__PURE__ */ new Date()).toISOString();
2134
+ await writeLearnStore(this.storePath, this.store);
2135
+ }
2136
+ };
2137
+
1935
2138
  // src/server/mcp.ts
1936
- import { appendFile as appendFile2, mkdir as mkdir6 } from "fs/promises";
1937
- import { dirname as dirname7 } from "path";
2139
+ import { appendFile as appendFile3, mkdir as mkdir7 } from "fs/promises";
2140
+ import { dirname as dirname8 } from "path";
1938
2141
 
1939
2142
  // src/graph/rank.ts
2143
+ var KW_BASE_WEIGHT = 2;
2144
+ var USAGE_BOOST_CAP_DEFAULT = 4;
2145
+ function usageBoostCap() {
2146
+ const env = Number(process.env.SYN_LEARN_BOOST_CAP);
2147
+ return Number.isFinite(env) && env >= 0 ? env : USAGE_BOOST_CAP_DEFAULT;
2148
+ }
1940
2149
  var STOPWORDS2 = /* @__PURE__ */ new Set([
1941
2150
  "a",
1942
2151
  "an",
@@ -2021,14 +2230,41 @@ function scoreFiles(inputs) {
2021
2230
  const importsFrom = indexImportEdges(inputs.graph);
2022
2231
  const seeds = new Set(inputs.sessionKnownPaths ?? []);
2023
2232
  for (const p of inputs.recentlyEditedPaths ?? []) seeds.add(p);
2233
+ const corpusSize = inputs.candidates.length;
2234
+ const queryDf = /* @__PURE__ */ new Map();
2235
+ for (const f of inputs.candidates) {
2236
+ for (const kw of f.keywords) {
2237
+ if (qTokens.has(kw)) queryDf.set(kw, (queryDf.get(kw) ?? 0) + 1);
2238
+ }
2239
+ }
2240
+ const idf = (token) => {
2241
+ const n = queryDf.get(token) ?? 0;
2242
+ if (n <= 0) return 0;
2243
+ return Math.log(1 + (corpusSize - n + 0.5) / (n + 0.5));
2244
+ };
2245
+ let idfSum = 0;
2246
+ let idfCount = 0;
2247
+ for (const t of qTokens) {
2248
+ const v = idf(t);
2249
+ if (v > 0) {
2250
+ idfSum += v;
2251
+ idfCount += 1;
2252
+ }
2253
+ }
2254
+ const refIdf = idfCount > 0 ? idfSum / idfCount : 1;
2024
2255
  const scored = [];
2025
2256
  for (const file of inputs.candidates) {
2026
2257
  const reasons = [];
2027
2258
  let score2 = 0;
2028
2259
  let kwHits = 0;
2029
- for (const kw of file.keywords) if (qTokens.has(kw)) kwHits += 1;
2260
+ let kwScore = 0;
2261
+ for (const kw of file.keywords) {
2262
+ if (!qTokens.has(kw)) continue;
2263
+ kwHits += 1;
2264
+ kwScore += KW_BASE_WEIGHT * (idf(kw) / refIdf);
2265
+ }
2030
2266
  if (kwHits) {
2031
- score2 += kwHits * 2;
2267
+ score2 += kwScore;
2032
2268
  reasons.push(`kw=${kwHits}`);
2033
2269
  }
2034
2270
  const symbols = symbolsByFile.get(file.path) ?? [];
@@ -2083,6 +2319,21 @@ function scoreFiles(inputs) {
2083
2319
  }
2084
2320
  }
2085
2321
  }
2322
+ const usage = inputs.usageScores;
2323
+ if (usage && usage.size > 0) {
2324
+ let maxU = 0;
2325
+ for (const v of usage.values()) if (v > maxU) maxU = v;
2326
+ if (maxU > 0) {
2327
+ const cap = usageBoostCap();
2328
+ for (const s of scored) {
2329
+ if (s.score <= 0) continue;
2330
+ const u = usage.get(s.file.path) ?? 0;
2331
+ if (u <= 0) continue;
2332
+ s.score += cap * (u / maxU);
2333
+ s.reasons.push(`used\xD7${Math.round(u)}`);
2334
+ }
2335
+ }
2336
+ }
2086
2337
  scored.sort((a, b) => b.score - a.score);
2087
2338
  return scored;
2088
2339
  }
@@ -2091,9 +2342,7 @@ function scoreFiles(inputs) {
2091
2342
  async function retrieve(graph, query, options = {}) {
2092
2343
  const topK = options.topK ?? 12;
2093
2344
  const qTokens = tokenizeQuery(query);
2094
- const allFiles = graph.nodes.filter(
2095
- (n) => n.kind === "file"
2096
- );
2345
+ const allFiles = graph.nodes.filter((n) => n.kind === "file");
2097
2346
  if (allFiles.length === 0 || qTokens.length === 0) {
2098
2347
  return {
2099
2348
  files: [],
@@ -2107,7 +2356,8 @@ async function retrieve(graph, query, options = {}) {
2107
2356
  query,
2108
2357
  graph,
2109
2358
  recentlyEditedPaths: options.recentlyEditedPaths,
2110
- sessionKnownPaths: options.sessionKnownPaths
2359
+ sessionKnownPaths: options.sessionKnownPaths,
2360
+ usageScores: options.usageScores
2111
2361
  };
2112
2362
  const scored = scoreFiles(rankInputs);
2113
2363
  const positive = scored.filter((s) => s.score > 0);
@@ -2140,14 +2390,14 @@ async function retrieve(graph, query, options = {}) {
2140
2390
 
2141
2391
  // src/memory/branches.ts
2142
2392
  import { execFile as execFile2 } from "child_process";
2143
- import { readFile as readFile8 } from "fs/promises";
2393
+ import { readFile as readFile9 } from "fs/promises";
2144
2394
  import { join as join6 } from "path";
2145
2395
  import { promisify as promisify2 } from "util";
2146
2396
  var execFileAsync2 = promisify2(execFile2);
2147
2397
  async function currentBranch(projectRoot) {
2148
2398
  try {
2149
2399
  const headPath = join6(projectRoot, ".git", "HEAD");
2150
- const head = await readFile8(headPath, "utf8");
2400
+ const head = await readFile9(headPath, "utf8");
2151
2401
  const trimmed = head.trim();
2152
2402
  const match = trimmed.match(/^ref:\s+refs\/heads\/(.+)$/);
2153
2403
  if (match?.[1]) return match[1];
@@ -2197,8 +2447,8 @@ function resolveBranchPaths(contextDir, branch, isDefault) {
2197
2447
  }
2198
2448
 
2199
2449
  // src/memory/context-md.ts
2200
- import { mkdir as mkdir4, readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
2201
- import { dirname as dirname5 } from "path";
2450
+ import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
2451
+ import { dirname as dirname6 } from "path";
2202
2452
  var MAX_BULLETS = 3;
2203
2453
  function deriveContextMd(entries, branch) {
2204
2454
  const tasks = entries.filter((e) => e.type === "task").reverse();
@@ -2241,17 +2491,17 @@ function formatContextMd(ctx) {
2241
2491
  return lines.join("\n");
2242
2492
  }
2243
2493
  async function writeContextMd(path, ctx) {
2244
- await mkdir4(dirname5(path), { recursive: true });
2245
- await writeFile4(path, formatContextMd(ctx), "utf8");
2494
+ await mkdir5(dirname6(path), { recursive: true });
2495
+ await writeFile5(path, formatContextMd(ctx), "utf8");
2246
2496
  }
2247
2497
 
2248
2498
  // src/memory/context-store.ts
2249
- import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
2250
- import { dirname as dirname6 } from "path";
2499
+ import { mkdir as mkdir6, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
2500
+ import { dirname as dirname7 } from "path";
2251
2501
  var SCHEMA_VERSION2 = 1;
2252
2502
  async function readEntries(path) {
2253
2503
  try {
2254
- const raw = await readFile10(path, "utf8");
2504
+ const raw = await readFile11(path, "utf8");
2255
2505
  const parsed = JSON.parse(raw);
2256
2506
  return Array.isArray(parsed.entries) ? parsed.entries : [];
2257
2507
  } catch {
@@ -2259,9 +2509,9 @@ async function readEntries(path) {
2259
2509
  }
2260
2510
  }
2261
2511
  async function writeEntries(path, entries) {
2262
- await mkdir5(dirname6(path), { recursive: true });
2512
+ await mkdir6(dirname7(path), { recursive: true });
2263
2513
  const store = { schema_version: SCHEMA_VERSION2, entries };
2264
- await writeFile5(path, JSON.stringify(store, null, 2) + "\n", "utf8");
2514
+ await writeFile6(path, JSON.stringify(store, null, 2) + "\n", "utf8");
2265
2515
  }
2266
2516
  async function appendEntry(path, entry) {
2267
2517
  const entries = await readEntries(path);
@@ -2535,7 +2785,10 @@ var TOOLS = [
2535
2785
  inputSchema: {
2536
2786
  type: "object",
2537
2787
  properties: {
2538
- query: { type: "string", description: "Natural-language description of what you're looking for." }
2788
+ query: {
2789
+ type: "string",
2790
+ description: "Natural-language description of what you're looking for."
2791
+ }
2539
2792
  },
2540
2793
  required: ["query"]
2541
2794
  }
@@ -2691,9 +2944,7 @@ function blastRadius(args, ctx) {
2691
2944
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
2692
2945
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
2693
2946
  const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
2694
- const root = ctx.graph.nodes.find(
2695
- (n) => n.kind === "file" && n.path === filePath
2696
- );
2947
+ const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
2697
2948
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
2698
2949
  const incoming = /* @__PURE__ */ new Map();
2699
2950
  for (const e of ctx.graph.edges) {
@@ -2740,8 +2991,8 @@ var LIKELY_ENTRY_PATTERNS = [
2740
2991
  /(?:^|\/)index\.[a-z0-9_]+$/i,
2741
2992
  /(?:^|\/)app\.[a-z0-9_]+$/i,
2742
2993
  /(?:^|\/)entry\.[a-z0-9_]+$/i,
2743
- /(?:^|\/)cli[\/.]/i,
2744
- /(?:^|\/)bin[\/.]/i,
2994
+ /(?:^|\/)cli[/.]/i,
2995
+ /(?:^|\/)bin[/.]/i,
2745
2996
  /(?:^|\/)server\.[a-z0-9_]+$/i,
2746
2997
  /\.test\.[a-z0-9_]+$/i,
2747
2998
  /\.spec\.[a-z0-9_]+$/i,
@@ -2787,9 +3038,11 @@ async function graphContinue(args, ctx) {
2787
3038
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
2788
3039
  const retrieval = await retrieve(ctx.graph, query, {
2789
3040
  recentlyEditedPaths: ctx.activity.recentFilePaths(15 * 60 * 1e3),
2790
- sessionKnownPaths: getRegisteredEdits()
3041
+ sessionKnownPaths: getRegisteredEdits(),
3042
+ usageScores: ctx.learn?.effectiveScores()
2791
3043
  });
2792
3044
  const packed = await pack(retrieval.files, { query, graph: ctx.graph });
3045
+ await logAccess(ctx, { ts: nowIso(), path: "", source: "continue", query });
2793
3046
  const header = `Confidence: ${retrieval.confidence}
2794
3047
  Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
2795
3048
  Reason: ${retrieval.reason}
@@ -2807,7 +3060,7 @@ function resolveFileTarget(graph, filePath) {
2807
3060
  if (matches.length > 1) return { ambiguous: matches.map((n) => n.path) };
2808
3061
  return { none: true };
2809
3062
  }
2810
- function graphRead(args, ctx) {
3063
+ async function graphRead(args, ctx) {
2811
3064
  const target = typeof args?.target === "string" ? args.target : "";
2812
3065
  if (!target) return errorContent("graph_read: 'target' (string) is required");
2813
3066
  const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
@@ -2824,6 +3077,7 @@ function graphRead(args, ctx) {
2824
3077
  return errorContent(`graph_read: file not found in graph: ${filePath}`);
2825
3078
  }
2826
3079
  const fileNode = resolved.node;
3080
+ await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
2827
3081
  if (!symbolName) {
2828
3082
  return textContent(`# ${fileNode.path}
2829
3083
 
@@ -2845,10 +3099,21 @@ ${body}`
2845
3099
  );
2846
3100
  }
2847
3101
  var editedFiles = /* @__PURE__ */ new Set();
2848
- function graphRegisterEdit(args, _ctx) {
3102
+ async function graphRegisterEdit(args, ctx) {
2849
3103
  const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
2850
- for (const f of files) editedFiles.add(f);
2851
- return textContent(`Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`);
3104
+ for (const f of files) {
3105
+ const file = f;
3106
+ editedFiles.add(file);
3107
+ const resolved = resolveFileTarget(ctx.graph, file);
3108
+ await logAccess(ctx, {
3109
+ ts: nowIso(),
3110
+ path: "node" in resolved ? resolved.node.path : file,
3111
+ source: "register_edit"
3112
+ });
3113
+ }
3114
+ return textContent(
3115
+ `Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`
3116
+ );
2852
3117
  }
2853
3118
  function getRegisteredEdits() {
2854
3119
  return Array.from(editedFiles);
@@ -2884,9 +3149,7 @@ function recentActivity(args, ctx) {
2884
3149
  let events = ctx.activity.getEvents(sinceMs);
2885
3150
  if (limit) events = events.slice(-limit);
2886
3151
  if (events.length === 0) {
2887
- return textContent(
2888
- `No human-activity events since ${new Date(sinceMs).toISOString()}.`
2889
- );
3152
+ return textContent(`No human-activity events since ${new Date(sinceMs).toISOString()}.`);
2890
3153
  }
2891
3154
  const lines = [`# Recent human activity (${events.length} events)`, ""];
2892
3155
  for (const e of events) {
@@ -2918,8 +3181,8 @@ async function contextRecall(args, ctx) {
2918
3181
  }
2919
3182
  async function logToolCall(ctx, tool) {
2920
3183
  try {
2921
- await mkdir6(dirname7(ctx.paths.toolLog), { recursive: true });
2922
- await appendFile2(
3184
+ await mkdir7(dirname8(ctx.paths.toolLog), { recursive: true });
3185
+ await appendFile3(
2923
3186
  ctx.paths.toolLog,
2924
3187
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
2925
3188
  "utf8"
@@ -2927,6 +3190,16 @@ async function logToolCall(ctx, tool) {
2927
3190
  } catch {
2928
3191
  }
2929
3192
  }
3193
+ async function logAccess(ctx, ev) {
3194
+ try {
3195
+ if (ctx.learn) await ctx.learn.record(ev);
3196
+ else await appendAccess(ctx.paths.accessLog, ev);
3197
+ } catch {
3198
+ }
3199
+ }
3200
+ function nowIso() {
3201
+ return (/* @__PURE__ */ new Date()).toISOString();
3202
+ }
2930
3203
  async function handleMcpRequest(body, ctx) {
2931
3204
  if (!body || typeof body !== "object") {
2932
3205
  return err(null, ERR.invalidRequest, "Request body must be a JSON-RPC 2.0 object.");
@@ -2996,9 +3269,87 @@ async function handleActivity(sinceMs, ctx) {
2996
3269
  };
2997
3270
  }
2998
3271
 
3272
+ // src/memory/git-snapshot.ts
3273
+ import { execFile as execFile3 } from "child_process";
3274
+ import { promisify as promisify3 } from "util";
3275
+ var execFileAsync3 = promisify3(execFile3);
3276
+ var MAX_COMMITS = 5;
3277
+ var FIELD = "";
3278
+ async function getCommitsSince(projectRoot, sinceIso) {
3279
+ const args = [
3280
+ "log",
3281
+ `--max-count=${MAX_COMMITS}`,
3282
+ "--no-merges",
3283
+ `--pretty=format:%h${FIELD}%s${FIELD}%aI`
3284
+ ];
3285
+ if (Number.isFinite(Date.parse(sinceIso))) args.push(`--since=${sinceIso}`);
3286
+ try {
3287
+ const { stdout } = await execFileAsync3("git", args, { cwd: projectRoot });
3288
+ const out = [];
3289
+ for (const line of stdout.split("\n")) {
3290
+ const t = line.trim();
3291
+ if (!t) continue;
3292
+ const [hash, message, date] = t.split(FIELD);
3293
+ if (hash && message) out.push({ hash, message, date: date ?? "" });
3294
+ }
3295
+ return out;
3296
+ } catch {
3297
+ return [];
3298
+ }
3299
+ }
3300
+
3301
+ // src/memory/session.ts
3302
+ import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile7 } from "fs/promises";
3303
+ import { dirname as dirname9 } from "path";
3304
+ var SESSION_SCHEMA_VERSION = 1;
3305
+ async function readSession(path) {
3306
+ try {
3307
+ const raw = await readFile12(path, "utf8");
3308
+ const parsed = JSON.parse(raw);
3309
+ if (parsed.schema_version !== SESSION_SCHEMA_VERSION) return null;
3310
+ return parsed;
3311
+ } catch {
3312
+ return null;
3313
+ }
3314
+ }
3315
+ async function writeSession(path, state) {
3316
+ await mkdir8(dirname9(path), { recursive: true });
3317
+ await writeFile7(path, JSON.stringify(state, null, 2) + "\n", "utf8");
3318
+ }
3319
+
2999
3320
  // src/server/routes/context-update.ts
3321
+ var TOUCHED_WINDOW_MS = 24 * 60 * 60 * 1e3;
3322
+ async function captureSnapshot(ctx, branchOverride) {
3323
+ const active = await resolveActiveBranch(ctx.paths, branchOverride);
3324
+ const [tasks, decisions, next] = await Promise.all([
3325
+ recallEntries(ctx.paths, { kind: "task", branch: active.branch, limit: 1 }),
3326
+ recallEntries(ctx.paths, { kind: "decision", branch: active.branch, limit: 3 }),
3327
+ recallEntries(ctx.paths, { kind: "next", branch: active.branch, limit: 3 })
3328
+ ]);
3329
+ const touched = new Set(getRegisteredEdits());
3330
+ for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
3331
+ const prev = await readSession(ctx.paths.sessionState);
3332
+ const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
3333
+ const snapshot = {
3334
+ schema_version: SESSION_SCHEMA_VERSION,
3335
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3336
+ branch: active.branch,
3337
+ filesTouched: Array.from(touched),
3338
+ recentCommits,
3339
+ summary: {
3340
+ tasks: tasks.entries.map((e) => e.content),
3341
+ decisions: decisions.entries.map((e) => e.content),
3342
+ next: next.entries.map((e) => e.content)
3343
+ }
3344
+ };
3345
+ await writeSession(ctx.paths.sessionState, snapshot);
3346
+ }
3000
3347
  async function handleContextUpdate(req, ctx) {
3001
3348
  const r = await refreshContextMd(ctx.paths, req?.branch);
3349
+ try {
3350
+ await captureSnapshot(ctx, req?.branch);
3351
+ } catch {
3352
+ }
3002
3353
  return {
3003
3354
  updated: true,
3004
3355
  branch: r.branch,
@@ -3008,8 +3359,8 @@ async function handleContextUpdate(req, ctx) {
3008
3359
  }
3009
3360
 
3010
3361
  // src/server/routes/gate.ts
3011
- import { appendFile as appendFile3, mkdir as mkdir7 } from "fs/promises";
3012
- import { dirname as dirname8 } from "path";
3362
+ import { appendFile as appendFile4, mkdir as mkdir9 } from "fs/promises";
3363
+ import { dirname as dirname10 } from "path";
3013
3364
  var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
3014
3365
  var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
3015
3366
  function extractQuery(toolName, input) {
@@ -3065,7 +3416,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
3065
3416
  }
3066
3417
  async function logDecision(ctx, toolName, query, decision, reason) {
3067
3418
  try {
3068
- await mkdir7(dirname8(ctx.paths.gateLog), { recursive: true });
3419
+ await mkdir9(dirname10(ctx.paths.gateLog), { recursive: true });
3069
3420
  const entry = {
3070
3421
  ts: (/* @__PURE__ */ new Date()).toISOString(),
3071
3422
  tool: toolName,
@@ -3073,7 +3424,7 @@ async function logDecision(ctx, toolName, query, decision, reason) {
3073
3424
  query,
3074
3425
  reason
3075
3426
  };
3076
- await appendFile3(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
3427
+ await appendFile4(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
3077
3428
  } catch {
3078
3429
  }
3079
3430
  }
@@ -3137,16 +3488,16 @@ async function handleGate(req, ctx) {
3137
3488
  }
3138
3489
 
3139
3490
  // src/server/routes/log.ts
3140
- import { appendFile as appendFile4, mkdir as mkdir8 } from "fs/promises";
3141
- import { dirname as dirname9 } from "path";
3491
+ import { appendFile as appendFile5, mkdir as mkdir10 } from "fs/promises";
3492
+ import { dirname as dirname11 } from "path";
3142
3493
  async function handleLog(entry, ctx) {
3143
3494
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
3144
3495
  throw new Error("log: input_tokens and output_tokens (number) are required");
3145
3496
  }
3146
3497
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
3147
3498
  const record = { ...entry, written_at };
3148
- await mkdir8(dirname9(ctx.paths.tokenLog), { recursive: true });
3149
- await appendFile4(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
3499
+ await mkdir10(dirname11(ctx.paths.tokenLog), { recursive: true });
3500
+ await appendFile5(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
3150
3501
  return { ok: true, written_at };
3151
3502
  }
3152
3503
 
@@ -3156,13 +3507,15 @@ async function handlePack(req, ctx) {
3156
3507
  throw new Error("pack: 'query' (string) is required");
3157
3508
  }
3158
3509
  const recentlyEditedPaths = ctx.activity.recentFilePaths(15 * 60 * 1e3);
3159
- const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths });
3510
+ const usageScores = ctx.learn?.effectiveScores();
3511
+ const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths, usageScores });
3160
3512
  const allFiles = ctx.graph.nodes.filter((n) => n.kind === "file");
3161
3513
  const scored = scoreFiles({
3162
3514
  candidates: allFiles,
3163
3515
  query: req.query,
3164
3516
  graph: ctx.graph,
3165
- recentlyEditedPaths
3517
+ recentlyEditedPaths,
3518
+ usageScores
3166
3519
  });
3167
3520
  const reasons = /* @__PURE__ */ new Map();
3168
3521
  for (const s of scored) {
@@ -3184,14 +3537,74 @@ async function handlePack(req, ctx) {
3184
3537
  }
3185
3538
 
3186
3539
  // src/server/routes/prime.ts
3187
- async function handlePrime(ctx, port) {
3540
+ var RESUME_PRIMER_MAX_CHARS = 2720;
3541
+ var MAX_FILES = 15;
3542
+ var MAX_COMMITS2 = 5;
3543
+ var MAX_BULLETS2 = 3;
3544
+ function legacyPrimer(ctx) {
3188
3545
  const g = ctx.graph;
3189
- const fileCount = g.file_count;
3190
- const symbolCount = g.symbol_count;
3191
- const primer = `Synthra context loaded for ${g.root}.
3192
- ${fileCount} files indexed, ${symbolCount} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.
3193
- (Full primer wired in M3.)`;
3194
- return { primer, port };
3546
+ return `Synthra context loaded for ${g.root}.
3547
+ ${g.file_count} files indexed, ${g.symbol_count} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.`;
3548
+ }
3549
+ function hasContent(snap) {
3550
+ return Boolean(
3551
+ snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
3552
+ );
3553
+ }
3554
+ function buildResumeDigest(snap, branchNow) {
3555
+ const plural = (n) => n === 1 ? "" : "s";
3556
+ const head = `## Since you were last here \u2014 ${snap.branch} (${snap.recentCommits.length} commit${plural(snap.recentCommits.length)}, ${snap.filesTouched.length} file${plural(snap.filesTouched.length)} touched)`;
3557
+ const essential = [head];
3558
+ if (snap.branch !== branchNow) {
3559
+ essential.push("");
3560
+ essential.push(
3561
+ `_(snapshot was for branch '${snap.branch}'; you're now on '${branchNow}' \u2014 may be stale)_`
3562
+ );
3563
+ }
3564
+ if (snap.summary.tasks[0]) {
3565
+ essential.push("", "### In progress", `- ${snap.summary.tasks[0]}`);
3566
+ }
3567
+ if (snap.summary.next.length) {
3568
+ essential.push("", "### Open next steps");
3569
+ for (const n of snap.summary.next.slice(0, MAX_BULLETS2)) essential.push(`- ${n}`);
3570
+ }
3571
+ if (snap.summary.decisions.length) {
3572
+ essential.push("", "### Recent decisions");
3573
+ for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
3574
+ }
3575
+ const extra = [];
3576
+ if (snap.recentCommits.length) {
3577
+ extra.push("", "### Recent commits");
3578
+ for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
3579
+ const date = c.date ? ` (${c.date.slice(0, 10)})` : "";
3580
+ extra.push(`- \`${c.hash}\` ${c.message}${date}`);
3581
+ }
3582
+ }
3583
+ if (snap.filesTouched.length) {
3584
+ const shown = snap.filesTouched.slice(0, MAX_FILES);
3585
+ const more = snap.filesTouched.length - shown.length;
3586
+ extra.push("", "### Files touched", shown.join(", ") + (more > 0 ? `, +${more} more` : ""));
3587
+ }
3588
+ let out = essential.join("\n");
3589
+ for (const line of extra) {
3590
+ if ((out + "\n" + line).length > RESUME_PRIMER_MAX_CHARS) break;
3591
+ out += "\n" + line;
3592
+ }
3593
+ return (out.length > RESUME_PRIMER_MAX_CHARS ? out.slice(0, RESUME_PRIMER_MAX_CHARS) : out).trimEnd();
3594
+ }
3595
+ async function handlePrime(ctx, port) {
3596
+ const legacy = legacyPrimer(ctx);
3597
+ const snap = await readSession(ctx.paths.sessionState);
3598
+ if (!snap || !hasContent(snap)) {
3599
+ return { primer: legacy, port };
3600
+ }
3601
+ const branchNow = await currentBranch(ctx.paths.projectRoot);
3602
+ const digest = buildResumeDigest(snap, branchNow);
3603
+ return { primer: `${digest}
3604
+
3605
+ ---
3606
+
3607
+ ${legacy}`, port };
3195
3608
  }
3196
3609
 
3197
3610
  // src/server/http.ts
@@ -3202,9 +3615,7 @@ async function loadContext(paths) {
3202
3615
  readSymbolIndex(paths.symbolIndex)
3203
3616
  ]);
3204
3617
  if (graph.schema_version !== SCHEMA_VERSION) {
3205
- log.info(
3206
- `graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION} \u2014 rescanning\u2026`
3207
- );
3618
+ log.info(`graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION} \u2014 rescanning\u2026`);
3208
3619
  await scanProject(paths.projectRoot, { silent: true });
3209
3620
  [graph, symbolIndex] = await Promise.all([
3210
3621
  readGraph(paths.infoGraph),
@@ -3212,7 +3623,8 @@ async function loadContext(paths) {
3212
3623
  ]);
3213
3624
  }
3214
3625
  const activity = new ActivityStore(paths.activityLog);
3215
- return { paths, graph, symbolIndex, activity };
3626
+ const learn = await LearnRuntime.load(paths.accessLog, paths.learnStore);
3627
+ return { paths, graph, symbolIndex, activity, learn };
3216
3628
  } catch (err2) {
3217
3629
  throw new Error(
3218
3630
  `failed to load graph from ${paths.infoGraph}: ${err2.message}. Run \`syn scan\` first.`
@@ -3249,9 +3661,7 @@ function buildApp(ctx, port) {
3249
3661
  app.get("/activity", async (c) => {
3250
3662
  const sinceParam = c.req.query("since");
3251
3663
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;
3252
- return c.json(
3253
- await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx)
3254
- );
3664
+ return c.json(await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx));
3255
3665
  });
3256
3666
  app.post("/context-update", async (c) => {
3257
3667
  const body = await c.req.json().catch(() => ({}));
@@ -3272,11 +3682,8 @@ async function startServer(paths, options = {}) {
3272
3682
  const port = options.port ?? await findFreePort();
3273
3683
  const app = buildApp(ctx, port);
3274
3684
  const nodeServer = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" });
3275
- await writeFile6(paths.mcpPort, String(port), "utf8");
3276
- const fileWatcher = createFileWatcher(
3277
- paths.projectRoot,
3278
- (e) => ctx.activity.add(e)
3279
- );
3685
+ await writeFile8(paths.mcpPort, String(port), "utf8");
3686
+ const fileWatcher = createFileWatcher(paths.projectRoot, (e) => ctx.activity.add(e));
3280
3687
  const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
3281
3688
  await ctx.activity.add(e);
3282
3689
  if (e.kind === "branch-switch") {
@@ -3313,6 +3720,7 @@ async function startServer(paths, options = {}) {
3313
3720
  async stop() {
3314
3721
  await fileWatcher.stop().catch(() => void 0);
3315
3722
  await gitWatcher.stop().catch(() => void 0);
3723
+ await ctx.learn?.flush().catch(() => void 0);
3316
3724
  await new Promise((resolve2, reject) => {
3317
3725
  nodeServer.close((err2) => err2 ? reject(err2) : resolve2());
3318
3726
  });