@jefuriiij/synthra 0.1.25 → 0.2.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.
@@ -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,191 @@ 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 USAGE_BOOST_CAP_DEFAULT = 4;
2144
+ function usageBoostCap() {
2145
+ const env = Number(process.env.SYN_LEARN_BOOST_CAP);
2146
+ return Number.isFinite(env) && env >= 0 ? env : USAGE_BOOST_CAP_DEFAULT;
2147
+ }
1940
2148
  var STOPWORDS2 = /* @__PURE__ */ new Set([
1941
2149
  "a",
1942
2150
  "an",
@@ -2083,6 +2291,21 @@ function scoreFiles(inputs) {
2083
2291
  }
2084
2292
  }
2085
2293
  }
2294
+ const usage = inputs.usageScores;
2295
+ if (usage && usage.size > 0) {
2296
+ let maxU = 0;
2297
+ for (const v of usage.values()) if (v > maxU) maxU = v;
2298
+ if (maxU > 0) {
2299
+ const cap = usageBoostCap();
2300
+ for (const s of scored) {
2301
+ if (s.score <= 0) continue;
2302
+ const u = usage.get(s.file.path) ?? 0;
2303
+ if (u <= 0) continue;
2304
+ s.score += cap * (u / maxU);
2305
+ s.reasons.push(`used\xD7${Math.round(u)}`);
2306
+ }
2307
+ }
2308
+ }
2086
2309
  scored.sort((a, b) => b.score - a.score);
2087
2310
  return scored;
2088
2311
  }
@@ -2091,9 +2314,7 @@ function scoreFiles(inputs) {
2091
2314
  async function retrieve(graph, query, options = {}) {
2092
2315
  const topK = options.topK ?? 12;
2093
2316
  const qTokens = tokenizeQuery(query);
2094
- const allFiles = graph.nodes.filter(
2095
- (n) => n.kind === "file"
2096
- );
2317
+ const allFiles = graph.nodes.filter((n) => n.kind === "file");
2097
2318
  if (allFiles.length === 0 || qTokens.length === 0) {
2098
2319
  return {
2099
2320
  files: [],
@@ -2107,7 +2328,8 @@ async function retrieve(graph, query, options = {}) {
2107
2328
  query,
2108
2329
  graph,
2109
2330
  recentlyEditedPaths: options.recentlyEditedPaths,
2110
- sessionKnownPaths: options.sessionKnownPaths
2331
+ sessionKnownPaths: options.sessionKnownPaths,
2332
+ usageScores: options.usageScores
2111
2333
  };
2112
2334
  const scored = scoreFiles(rankInputs);
2113
2335
  const positive = scored.filter((s) => s.score > 0);
@@ -2140,14 +2362,14 @@ async function retrieve(graph, query, options = {}) {
2140
2362
 
2141
2363
  // src/memory/branches.ts
2142
2364
  import { execFile as execFile2 } from "child_process";
2143
- import { readFile as readFile8 } from "fs/promises";
2365
+ import { readFile as readFile9 } from "fs/promises";
2144
2366
  import { join as join6 } from "path";
2145
2367
  import { promisify as promisify2 } from "util";
2146
2368
  var execFileAsync2 = promisify2(execFile2);
2147
2369
  async function currentBranch(projectRoot) {
2148
2370
  try {
2149
2371
  const headPath = join6(projectRoot, ".git", "HEAD");
2150
- const head = await readFile8(headPath, "utf8");
2372
+ const head = await readFile9(headPath, "utf8");
2151
2373
  const trimmed = head.trim();
2152
2374
  const match = trimmed.match(/^ref:\s+refs\/heads\/(.+)$/);
2153
2375
  if (match?.[1]) return match[1];
@@ -2197,8 +2419,8 @@ function resolveBranchPaths(contextDir, branch, isDefault) {
2197
2419
  }
2198
2420
 
2199
2421
  // 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";
2422
+ import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
2423
+ import { dirname as dirname6 } from "path";
2202
2424
  var MAX_BULLETS = 3;
2203
2425
  function deriveContextMd(entries, branch) {
2204
2426
  const tasks = entries.filter((e) => e.type === "task").reverse();
@@ -2241,17 +2463,17 @@ function formatContextMd(ctx) {
2241
2463
  return lines.join("\n");
2242
2464
  }
2243
2465
  async function writeContextMd(path, ctx) {
2244
- await mkdir4(dirname5(path), { recursive: true });
2245
- await writeFile4(path, formatContextMd(ctx), "utf8");
2466
+ await mkdir5(dirname6(path), { recursive: true });
2467
+ await writeFile5(path, formatContextMd(ctx), "utf8");
2246
2468
  }
2247
2469
 
2248
2470
  // 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";
2471
+ import { mkdir as mkdir6, readFile as readFile11, writeFile as writeFile6 } from "fs/promises";
2472
+ import { dirname as dirname7 } from "path";
2251
2473
  var SCHEMA_VERSION2 = 1;
2252
2474
  async function readEntries(path) {
2253
2475
  try {
2254
- const raw = await readFile10(path, "utf8");
2476
+ const raw = await readFile11(path, "utf8");
2255
2477
  const parsed = JSON.parse(raw);
2256
2478
  return Array.isArray(parsed.entries) ? parsed.entries : [];
2257
2479
  } catch {
@@ -2259,9 +2481,9 @@ async function readEntries(path) {
2259
2481
  }
2260
2482
  }
2261
2483
  async function writeEntries(path, entries) {
2262
- await mkdir5(dirname6(path), { recursive: true });
2484
+ await mkdir6(dirname7(path), { recursive: true });
2263
2485
  const store = { schema_version: SCHEMA_VERSION2, entries };
2264
- await writeFile5(path, JSON.stringify(store, null, 2) + "\n", "utf8");
2486
+ await writeFile6(path, JSON.stringify(store, null, 2) + "\n", "utf8");
2265
2487
  }
2266
2488
  async function appendEntry(path, entry) {
2267
2489
  const entries = await readEntries(path);
@@ -2535,7 +2757,10 @@ var TOOLS = [
2535
2757
  inputSchema: {
2536
2758
  type: "object",
2537
2759
  properties: {
2538
- query: { type: "string", description: "Natural-language description of what you're looking for." }
2760
+ query: {
2761
+ type: "string",
2762
+ description: "Natural-language description of what you're looking for."
2763
+ }
2539
2764
  },
2540
2765
  required: ["query"]
2541
2766
  }
@@ -2691,9 +2916,7 @@ function blastRadius(args, ctx) {
2691
2916
  const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
2692
2917
  if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
2693
2918
  const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
2694
- const root = ctx.graph.nodes.find(
2695
- (n) => n.kind === "file" && n.path === filePath
2696
- );
2919
+ const root = ctx.graph.nodes.find((n) => n.kind === "file" && n.path === filePath);
2697
2920
  if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
2698
2921
  const incoming = /* @__PURE__ */ new Map();
2699
2922
  for (const e of ctx.graph.edges) {
@@ -2740,8 +2963,8 @@ var LIKELY_ENTRY_PATTERNS = [
2740
2963
  /(?:^|\/)index\.[a-z0-9_]+$/i,
2741
2964
  /(?:^|\/)app\.[a-z0-9_]+$/i,
2742
2965
  /(?:^|\/)entry\.[a-z0-9_]+$/i,
2743
- /(?:^|\/)cli[\/.]/i,
2744
- /(?:^|\/)bin[\/.]/i,
2966
+ /(?:^|\/)cli[/.]/i,
2967
+ /(?:^|\/)bin[/.]/i,
2745
2968
  /(?:^|\/)server\.[a-z0-9_]+$/i,
2746
2969
  /\.test\.[a-z0-9_]+$/i,
2747
2970
  /\.spec\.[a-z0-9_]+$/i,
@@ -2787,9 +3010,11 @@ async function graphContinue(args, ctx) {
2787
3010
  if (!query) return errorContent("graph_continue: 'query' (string) is required");
2788
3011
  const retrieval = await retrieve(ctx.graph, query, {
2789
3012
  recentlyEditedPaths: ctx.activity.recentFilePaths(15 * 60 * 1e3),
2790
- sessionKnownPaths: getRegisteredEdits()
3013
+ sessionKnownPaths: getRegisteredEdits(),
3014
+ usageScores: ctx.learn?.effectiveScores()
2791
3015
  });
2792
3016
  const packed = await pack(retrieval.files, { query, graph: ctx.graph });
3017
+ await logAccess(ctx, { ts: nowIso(), path: "", source: "continue", query });
2793
3018
  const header = `Confidence: ${retrieval.confidence}
2794
3019
  Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
2795
3020
  Reason: ${retrieval.reason}
@@ -2807,7 +3032,7 @@ function resolveFileTarget(graph, filePath) {
2807
3032
  if (matches.length > 1) return { ambiguous: matches.map((n) => n.path) };
2808
3033
  return { none: true };
2809
3034
  }
2810
- function graphRead(args, ctx) {
3035
+ async function graphRead(args, ctx) {
2811
3036
  const target = typeof args?.target === "string" ? args.target : "";
2812
3037
  if (!target) return errorContent("graph_read: 'target' (string) is required");
2813
3038
  const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
@@ -2824,6 +3049,7 @@ function graphRead(args, ctx) {
2824
3049
  return errorContent(`graph_read: file not found in graph: ${filePath}`);
2825
3050
  }
2826
3051
  const fileNode = resolved.node;
3052
+ await logAccess(ctx, { ts: nowIso(), path: fileNode.path, source: "read" });
2827
3053
  if (!symbolName) {
2828
3054
  return textContent(`# ${fileNode.path}
2829
3055
 
@@ -2845,10 +3071,21 @@ ${body}`
2845
3071
  );
2846
3072
  }
2847
3073
  var editedFiles = /* @__PURE__ */ new Set();
2848
- function graphRegisterEdit(args, _ctx) {
3074
+ async function graphRegisterEdit(args, ctx) {
2849
3075
  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}.`);
3076
+ for (const f of files) {
3077
+ const file = f;
3078
+ editedFiles.add(file);
3079
+ const resolved = resolveFileTarget(ctx.graph, file);
3080
+ await logAccess(ctx, {
3081
+ ts: nowIso(),
3082
+ path: "node" in resolved ? resolved.node.path : file,
3083
+ source: "register_edit"
3084
+ });
3085
+ }
3086
+ return textContent(
3087
+ `Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`
3088
+ );
2852
3089
  }
2853
3090
  function getRegisteredEdits() {
2854
3091
  return Array.from(editedFiles);
@@ -2884,9 +3121,7 @@ function recentActivity(args, ctx) {
2884
3121
  let events = ctx.activity.getEvents(sinceMs);
2885
3122
  if (limit) events = events.slice(-limit);
2886
3123
  if (events.length === 0) {
2887
- return textContent(
2888
- `No human-activity events since ${new Date(sinceMs).toISOString()}.`
2889
- );
3124
+ return textContent(`No human-activity events since ${new Date(sinceMs).toISOString()}.`);
2890
3125
  }
2891
3126
  const lines = [`# Recent human activity (${events.length} events)`, ""];
2892
3127
  for (const e of events) {
@@ -2918,8 +3153,8 @@ async function contextRecall(args, ctx) {
2918
3153
  }
2919
3154
  async function logToolCall(ctx, tool) {
2920
3155
  try {
2921
- await mkdir6(dirname7(ctx.paths.toolLog), { recursive: true });
2922
- await appendFile2(
3156
+ await mkdir7(dirname8(ctx.paths.toolLog), { recursive: true });
3157
+ await appendFile3(
2923
3158
  ctx.paths.toolLog,
2924
3159
  JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), tool }) + "\n",
2925
3160
  "utf8"
@@ -2927,6 +3162,16 @@ async function logToolCall(ctx, tool) {
2927
3162
  } catch {
2928
3163
  }
2929
3164
  }
3165
+ async function logAccess(ctx, ev) {
3166
+ try {
3167
+ if (ctx.learn) await ctx.learn.record(ev);
3168
+ else await appendAccess(ctx.paths.accessLog, ev);
3169
+ } catch {
3170
+ }
3171
+ }
3172
+ function nowIso() {
3173
+ return (/* @__PURE__ */ new Date()).toISOString();
3174
+ }
2930
3175
  async function handleMcpRequest(body, ctx) {
2931
3176
  if (!body || typeof body !== "object") {
2932
3177
  return err(null, ERR.invalidRequest, "Request body must be a JSON-RPC 2.0 object.");
@@ -2996,9 +3241,87 @@ async function handleActivity(sinceMs, ctx) {
2996
3241
  };
2997
3242
  }
2998
3243
 
3244
+ // src/memory/git-snapshot.ts
3245
+ import { execFile as execFile3 } from "child_process";
3246
+ import { promisify as promisify3 } from "util";
3247
+ var execFileAsync3 = promisify3(execFile3);
3248
+ var MAX_COMMITS = 5;
3249
+ var FIELD = "";
3250
+ async function getCommitsSince(projectRoot, sinceIso) {
3251
+ const args = [
3252
+ "log",
3253
+ `--max-count=${MAX_COMMITS}`,
3254
+ "--no-merges",
3255
+ `--pretty=format:%h${FIELD}%s${FIELD}%aI`
3256
+ ];
3257
+ if (Number.isFinite(Date.parse(sinceIso))) args.push(`--since=${sinceIso}`);
3258
+ try {
3259
+ const { stdout } = await execFileAsync3("git", args, { cwd: projectRoot });
3260
+ const out = [];
3261
+ for (const line of stdout.split("\n")) {
3262
+ const t = line.trim();
3263
+ if (!t) continue;
3264
+ const [hash, message, date] = t.split(FIELD);
3265
+ if (hash && message) out.push({ hash, message, date: date ?? "" });
3266
+ }
3267
+ return out;
3268
+ } catch {
3269
+ return [];
3270
+ }
3271
+ }
3272
+
3273
+ // src/memory/session.ts
3274
+ import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile7 } from "fs/promises";
3275
+ import { dirname as dirname9 } from "path";
3276
+ var SESSION_SCHEMA_VERSION = 1;
3277
+ async function readSession(path) {
3278
+ try {
3279
+ const raw = await readFile12(path, "utf8");
3280
+ const parsed = JSON.parse(raw);
3281
+ if (parsed.schema_version !== SESSION_SCHEMA_VERSION) return null;
3282
+ return parsed;
3283
+ } catch {
3284
+ return null;
3285
+ }
3286
+ }
3287
+ async function writeSession(path, state) {
3288
+ await mkdir8(dirname9(path), { recursive: true });
3289
+ await writeFile7(path, JSON.stringify(state, null, 2) + "\n", "utf8");
3290
+ }
3291
+
2999
3292
  // src/server/routes/context-update.ts
3293
+ var TOUCHED_WINDOW_MS = 24 * 60 * 60 * 1e3;
3294
+ async function captureSnapshot(ctx, branchOverride) {
3295
+ const active = await resolveActiveBranch(ctx.paths, branchOverride);
3296
+ const [tasks, decisions, next] = await Promise.all([
3297
+ recallEntries(ctx.paths, { kind: "task", branch: active.branch, limit: 1 }),
3298
+ recallEntries(ctx.paths, { kind: "decision", branch: active.branch, limit: 3 }),
3299
+ recallEntries(ctx.paths, { kind: "next", branch: active.branch, limit: 3 })
3300
+ ]);
3301
+ const touched = new Set(getRegisteredEdits());
3302
+ for (const p of ctx.activity.recentFilePaths(TOUCHED_WINDOW_MS)) touched.add(p);
3303
+ const prev = await readSession(ctx.paths.sessionState);
3304
+ const recentCommits = await getCommitsSince(ctx.paths.projectRoot, prev?.endedAt ?? "");
3305
+ const snapshot = {
3306
+ schema_version: SESSION_SCHEMA_VERSION,
3307
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3308
+ branch: active.branch,
3309
+ filesTouched: Array.from(touched),
3310
+ recentCommits,
3311
+ summary: {
3312
+ tasks: tasks.entries.map((e) => e.content),
3313
+ decisions: decisions.entries.map((e) => e.content),
3314
+ next: next.entries.map((e) => e.content)
3315
+ }
3316
+ };
3317
+ await writeSession(ctx.paths.sessionState, snapshot);
3318
+ }
3000
3319
  async function handleContextUpdate(req, ctx) {
3001
3320
  const r = await refreshContextMd(ctx.paths, req?.branch);
3321
+ try {
3322
+ await captureSnapshot(ctx, req?.branch);
3323
+ } catch {
3324
+ }
3002
3325
  return {
3003
3326
  updated: true,
3004
3327
  branch: r.branch,
@@ -3008,8 +3331,8 @@ async function handleContextUpdate(req, ctx) {
3008
3331
  }
3009
3332
 
3010
3333
  // src/server/routes/gate.ts
3011
- import { appendFile as appendFile3, mkdir as mkdir7 } from "fs/promises";
3012
- import { dirname as dirname8 } from "path";
3334
+ import { appendFile as appendFile4, mkdir as mkdir9 } from "fs/promises";
3335
+ import { dirname as dirname10 } from "path";
3013
3336
  var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
3014
3337
  var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
3015
3338
  function extractQuery(toolName, input) {
@@ -3065,7 +3388,7 @@ function recentlyTouchedMatchesQuery(recentPaths, queryTokens, graph) {
3065
3388
  }
3066
3389
  async function logDecision(ctx, toolName, query, decision, reason) {
3067
3390
  try {
3068
- await mkdir7(dirname8(ctx.paths.gateLog), { recursive: true });
3391
+ await mkdir9(dirname10(ctx.paths.gateLog), { recursive: true });
3069
3392
  const entry = {
3070
3393
  ts: (/* @__PURE__ */ new Date()).toISOString(),
3071
3394
  tool: toolName,
@@ -3073,7 +3396,7 @@ async function logDecision(ctx, toolName, query, decision, reason) {
3073
3396
  query,
3074
3397
  reason
3075
3398
  };
3076
- await appendFile3(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
3399
+ await appendFile4(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
3077
3400
  } catch {
3078
3401
  }
3079
3402
  }
@@ -3137,16 +3460,16 @@ async function handleGate(req, ctx) {
3137
3460
  }
3138
3461
 
3139
3462
  // src/server/routes/log.ts
3140
- import { appendFile as appendFile4, mkdir as mkdir8 } from "fs/promises";
3141
- import { dirname as dirname9 } from "path";
3463
+ import { appendFile as appendFile5, mkdir as mkdir10 } from "fs/promises";
3464
+ import { dirname as dirname11 } from "path";
3142
3465
  async function handleLog(entry, ctx) {
3143
3466
  if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
3144
3467
  throw new Error("log: input_tokens and output_tokens (number) are required");
3145
3468
  }
3146
3469
  const written_at = (/* @__PURE__ */ new Date()).toISOString();
3147
3470
  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");
3471
+ await mkdir10(dirname11(ctx.paths.tokenLog), { recursive: true });
3472
+ await appendFile5(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
3150
3473
  return { ok: true, written_at };
3151
3474
  }
3152
3475
 
@@ -3156,13 +3479,15 @@ async function handlePack(req, ctx) {
3156
3479
  throw new Error("pack: 'query' (string) is required");
3157
3480
  }
3158
3481
  const recentlyEditedPaths = ctx.activity.recentFilePaths(15 * 60 * 1e3);
3159
- const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths });
3482
+ const usageScores = ctx.learn?.effectiveScores();
3483
+ const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths, usageScores });
3160
3484
  const allFiles = ctx.graph.nodes.filter((n) => n.kind === "file");
3161
3485
  const scored = scoreFiles({
3162
3486
  candidates: allFiles,
3163
3487
  query: req.query,
3164
3488
  graph: ctx.graph,
3165
- recentlyEditedPaths
3489
+ recentlyEditedPaths,
3490
+ usageScores
3166
3491
  });
3167
3492
  const reasons = /* @__PURE__ */ new Map();
3168
3493
  for (const s of scored) {
@@ -3184,14 +3509,74 @@ async function handlePack(req, ctx) {
3184
3509
  }
3185
3510
 
3186
3511
  // src/server/routes/prime.ts
3187
- async function handlePrime(ctx, port) {
3512
+ var RESUME_PRIMER_MAX_CHARS = 2720;
3513
+ var MAX_FILES = 15;
3514
+ var MAX_COMMITS2 = 5;
3515
+ var MAX_BULLETS2 = 3;
3516
+ function legacyPrimer(ctx) {
3188
3517
  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 };
3518
+ return `Synthra context loaded for ${g.root}.
3519
+ ${g.file_count} files indexed, ${g.symbol_count} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.`;
3520
+ }
3521
+ function hasContent(snap) {
3522
+ return Boolean(
3523
+ snap.recentCommits.length || snap.filesTouched.length || snap.summary.tasks.length || snap.summary.next.length || snap.summary.decisions.length
3524
+ );
3525
+ }
3526
+ function buildResumeDigest(snap, branchNow) {
3527
+ const plural = (n) => n === 1 ? "" : "s";
3528
+ 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)`;
3529
+ const essential = [head];
3530
+ if (snap.branch !== branchNow) {
3531
+ essential.push("");
3532
+ essential.push(
3533
+ `_(snapshot was for branch '${snap.branch}'; you're now on '${branchNow}' \u2014 may be stale)_`
3534
+ );
3535
+ }
3536
+ if (snap.summary.tasks[0]) {
3537
+ essential.push("", "### In progress", `- ${snap.summary.tasks[0]}`);
3538
+ }
3539
+ if (snap.summary.next.length) {
3540
+ essential.push("", "### Open next steps");
3541
+ for (const n of snap.summary.next.slice(0, MAX_BULLETS2)) essential.push(`- ${n}`);
3542
+ }
3543
+ if (snap.summary.decisions.length) {
3544
+ essential.push("", "### Recent decisions");
3545
+ for (const d of snap.summary.decisions.slice(0, MAX_BULLETS2)) essential.push(`- ${d}`);
3546
+ }
3547
+ const extra = [];
3548
+ if (snap.recentCommits.length) {
3549
+ extra.push("", "### Recent commits");
3550
+ for (const c of snap.recentCommits.slice(0, MAX_COMMITS2)) {
3551
+ const date = c.date ? ` (${c.date.slice(0, 10)})` : "";
3552
+ extra.push(`- \`${c.hash}\` ${c.message}${date}`);
3553
+ }
3554
+ }
3555
+ if (snap.filesTouched.length) {
3556
+ const shown = snap.filesTouched.slice(0, MAX_FILES);
3557
+ const more = snap.filesTouched.length - shown.length;
3558
+ extra.push("", "### Files touched", shown.join(", ") + (more > 0 ? `, +${more} more` : ""));
3559
+ }
3560
+ let out = essential.join("\n");
3561
+ for (const line of extra) {
3562
+ if ((out + "\n" + line).length > RESUME_PRIMER_MAX_CHARS) break;
3563
+ out += "\n" + line;
3564
+ }
3565
+ return (out.length > RESUME_PRIMER_MAX_CHARS ? out.slice(0, RESUME_PRIMER_MAX_CHARS) : out).trimEnd();
3566
+ }
3567
+ async function handlePrime(ctx, port) {
3568
+ const legacy = legacyPrimer(ctx);
3569
+ const snap = await readSession(ctx.paths.sessionState);
3570
+ if (!snap || !hasContent(snap)) {
3571
+ return { primer: legacy, port };
3572
+ }
3573
+ const branchNow = await currentBranch(ctx.paths.projectRoot);
3574
+ const digest = buildResumeDigest(snap, branchNow);
3575
+ return { primer: `${digest}
3576
+
3577
+ ---
3578
+
3579
+ ${legacy}`, port };
3195
3580
  }
3196
3581
 
3197
3582
  // src/server/http.ts
@@ -3202,9 +3587,7 @@ async function loadContext(paths) {
3202
3587
  readSymbolIndex(paths.symbolIndex)
3203
3588
  ]);
3204
3589
  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
- );
3590
+ log.info(`graph schema v${graph.schema_version} \u2260 current v${SCHEMA_VERSION} \u2014 rescanning\u2026`);
3208
3591
  await scanProject(paths.projectRoot, { silent: true });
3209
3592
  [graph, symbolIndex] = await Promise.all([
3210
3593
  readGraph(paths.infoGraph),
@@ -3212,7 +3595,8 @@ async function loadContext(paths) {
3212
3595
  ]);
3213
3596
  }
3214
3597
  const activity = new ActivityStore(paths.activityLog);
3215
- return { paths, graph, symbolIndex, activity };
3598
+ const learn = await LearnRuntime.load(paths.accessLog, paths.learnStore);
3599
+ return { paths, graph, symbolIndex, activity, learn };
3216
3600
  } catch (err2) {
3217
3601
  throw new Error(
3218
3602
  `failed to load graph from ${paths.infoGraph}: ${err2.message}. Run \`syn scan\` first.`
@@ -3249,9 +3633,7 @@ function buildApp(ctx, port) {
3249
3633
  app.get("/activity", async (c) => {
3250
3634
  const sinceParam = c.req.query("since");
3251
3635
  const sinceMs = sinceParam ? Number(sinceParam) : void 0;
3252
- return c.json(
3253
- await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx)
3254
- );
3636
+ return c.json(await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx));
3255
3637
  });
3256
3638
  app.post("/context-update", async (c) => {
3257
3639
  const body = await c.req.json().catch(() => ({}));
@@ -3272,11 +3654,8 @@ async function startServer(paths, options = {}) {
3272
3654
  const port = options.port ?? await findFreePort();
3273
3655
  const app = buildApp(ctx, port);
3274
3656
  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
- );
3657
+ await writeFile8(paths.mcpPort, String(port), "utf8");
3658
+ const fileWatcher = createFileWatcher(paths.projectRoot, (e) => ctx.activity.add(e));
3280
3659
  const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
3281
3660
  await ctx.activity.add(e);
3282
3661
  if (e.kind === "branch-switch") {
@@ -3313,6 +3692,7 @@ async function startServer(paths, options = {}) {
3313
3692
  async stop() {
3314
3693
  await fileWatcher.stop().catch(() => void 0);
3315
3694
  await gitWatcher.stop().catch(() => void 0);
3695
+ await ctx.learn?.flush().catch(() => void 0);
3316
3696
  await new Promise((resolve2, reject) => {
3317
3697
  nodeServer.close((err2) => err2 ? reject(err2) : resolve2());
3318
3698
  });