ricord 1.0.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.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +213 -0
  3. package/commands/ricord-flush.md +29 -0
  4. package/commands/ricord-init.md +129 -0
  5. package/commands/ricord-lint.md +64 -0
  6. package/commands/ricord-query.md +71 -0
  7. package/dist/cli/auth.d.ts +16 -0
  8. package/dist/cli/auth.js +42 -0
  9. package/dist/cli/auth.js.map +1 -0
  10. package/dist/cli/bundle.d.ts +25 -0
  11. package/dist/cli/bundle.js +179 -0
  12. package/dist/cli/bundle.js.map +1 -0
  13. package/dist/cli/cache.d.ts +18 -0
  14. package/dist/cli/cache.js +39 -0
  15. package/dist/cli/cache.js.map +1 -0
  16. package/dist/cli/cli.d.ts +21 -0
  17. package/dist/cli/cli.js +355 -0
  18. package/dist/cli/cli.js.map +1 -0
  19. package/dist/cli/client.d.ts +12 -0
  20. package/dist/cli/client.js +35 -0
  21. package/dist/cli/client.js.map +1 -0
  22. package/dist/cli/commands/build.d.ts +44 -0
  23. package/dist/cli/commands/build.js +437 -0
  24. package/dist/cli/commands/build.js.map +1 -0
  25. package/dist/cli/commands/curate.d.ts +32 -0
  26. package/dist/cli/commands/curate.js +154 -0
  27. package/dist/cli/commands/curate.js.map +1 -0
  28. package/dist/cli/commands/doctor.d.ts +16 -0
  29. package/dist/cli/commands/doctor.js +92 -0
  30. package/dist/cli/commands/doctor.js.map +1 -0
  31. package/dist/cli/commands/ingest.d.ts +25 -0
  32. package/dist/cli/commands/ingest.js +121 -0
  33. package/dist/cli/commands/ingest.js.map +1 -0
  34. package/dist/cli/commands/install.d.ts +16 -0
  35. package/dist/cli/commands/install.js +82 -0
  36. package/dist/cli/commands/install.js.map +1 -0
  37. package/dist/cli/commands/pull.d.ts +24 -0
  38. package/dist/cli/commands/pull.js +104 -0
  39. package/dist/cli/commands/pull.js.map +1 -0
  40. package/dist/cli/commands/push.d.ts +28 -0
  41. package/dist/cli/commands/push.js +164 -0
  42. package/dist/cli/commands/push.js.map +1 -0
  43. package/dist/cli/commands/rollup.d.ts +21 -0
  44. package/dist/cli/commands/rollup.js +118 -0
  45. package/dist/cli/commands/rollup.js.map +1 -0
  46. package/dist/cli/commands/setup.d.ts +7 -0
  47. package/dist/cli/commands/setup.js +43 -0
  48. package/dist/cli/commands/setup.js.map +1 -0
  49. package/dist/cli/commands/sync.d.ts +15 -0
  50. package/dist/cli/commands/sync.js +63 -0
  51. package/dist/cli/commands/sync.js.map +1 -0
  52. package/dist/cli/commands/watch.d.ts +17 -0
  53. package/dist/cli/commands/watch.js +87 -0
  54. package/dist/cli/commands/watch.js.map +1 -0
  55. package/dist/cli/config.d.ts +29 -0
  56. package/dist/cli/config.js +52 -0
  57. package/dist/cli/config.js.map +1 -0
  58. package/dist/cli/extract.d.ts +101 -0
  59. package/dist/cli/extract.js +216 -0
  60. package/dist/cli/extract.js.map +1 -0
  61. package/dist/cli/ingest.d.ts +48 -0
  62. package/dist/cli/ingest.js +74 -0
  63. package/dist/cli/ingest.js.map +1 -0
  64. package/dist/cli/ledger.d.ts +44 -0
  65. package/dist/cli/ledger.js +67 -0
  66. package/dist/cli/ledger.js.map +1 -0
  67. package/dist/cli/llm.d.ts +21 -0
  68. package/dist/cli/llm.js +138 -0
  69. package/dist/cli/llm.js.map +1 -0
  70. package/dist/cli/parse.d.ts +13 -0
  71. package/dist/cli/parse.js +188 -0
  72. package/dist/cli/parse.js.map +1 -0
  73. package/dist/cli/run-explore.d.ts +56 -0
  74. package/dist/cli/run-explore.js +229 -0
  75. package/dist/cli/run-explore.js.map +1 -0
  76. package/dist/cli/summarize.d.ts +15 -0
  77. package/dist/cli/summarize.js +49 -0
  78. package/dist/cli/summarize.js.map +1 -0
  79. package/dist/cli/uninstall.d.ts +6 -0
  80. package/dist/cli/uninstall.js +277 -0
  81. package/dist/cli/uninstall.js.map +1 -0
  82. package/dist/cli/walk.d.ts +13 -0
  83. package/dist/cli/walk.js +62 -0
  84. package/dist/cli/walk.js.map +1 -0
  85. package/dist/cli/walker.d.ts +14 -0
  86. package/dist/cli/walker.js +120 -0
  87. package/dist/cli/walker.js.map +1 -0
  88. package/dist/hooks/pre-compact.d.ts +15 -0
  89. package/dist/hooks/pre-compact.js +127 -0
  90. package/dist/hooks/pre-compact.js.map +1 -0
  91. package/dist/hooks/pre-tool-use.d.ts +15 -0
  92. package/dist/hooks/pre-tool-use.js +25 -0
  93. package/dist/hooks/pre-tool-use.js.map +1 -0
  94. package/dist/hooks/session-end.d.ts +21 -0
  95. package/dist/hooks/session-end.js +186 -0
  96. package/dist/hooks/session-end.js.map +1 -0
  97. package/dist/hooks/session-start.d.ts +15 -0
  98. package/dist/hooks/session-start.js +233 -0
  99. package/dist/hooks/session-start.js.map +1 -0
  100. package/dist/hooks/turn-end-post.d.ts +17 -0
  101. package/dist/hooks/turn-end-post.js +66 -0
  102. package/dist/hooks/turn-end-post.js.map +1 -0
  103. package/dist/hooks/turn-end.d.ts +29 -0
  104. package/dist/hooks/turn-end.js +295 -0
  105. package/dist/hooks/turn-end.js.map +1 -0
  106. package/dist/index.d.ts +24 -0
  107. package/dist/index.js +1547 -0
  108. package/dist/index.js.map +1 -0
  109. package/dist/init.d.ts +45 -0
  110. package/dist/init.js +839 -0
  111. package/dist/init.js.map +1 -0
  112. package/dist/lib/active-project.d.ts +14 -0
  113. package/dist/lib/active-project.js +65 -0
  114. package/dist/lib/active-project.js.map +1 -0
  115. package/dist/lib/buffer.d.ts +34 -0
  116. package/dist/lib/buffer.js +79 -0
  117. package/dist/lib/buffer.js.map +1 -0
  118. package/dist/scripts/compile.d.ts +25 -0
  119. package/dist/scripts/compile.js +185 -0
  120. package/dist/scripts/compile.js.map +1 -0
  121. package/dist/scripts/config.d.ts +30 -0
  122. package/dist/scripts/config.js +68 -0
  123. package/dist/scripts/config.js.map +1 -0
  124. package/dist/scripts/flush.d.ts +23 -0
  125. package/dist/scripts/flush.js +230 -0
  126. package/dist/scripts/flush.js.map +1 -0
  127. package/dist/scripts/lint.d.ts +21 -0
  128. package/dist/scripts/lint.js +242 -0
  129. package/dist/scripts/lint.js.map +1 -0
  130. package/dist/scripts/utils.d.ts +43 -0
  131. package/dist/scripts/utils.js +165 -0
  132. package/dist/scripts/utils.js.map +1 -0
  133. package/package.json +74 -0
  134. package/scripts/postinstall.mjs +56 -0
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart hook — injects Ricord knowledge context into every Claude Code session.
4
+ *
5
+ * Single API call: GET /v1/user/boot-context returns the full session-start
6
+ * digest (instructions, preferences, top procedures, open tasks, active
7
+ * projects, counts). Plus the most recent daily log from disk if any.
8
+ *
9
+ * 2026-05-01 simplification: dropped the previous 3-call sequence (search +
10
+ * boot-context + pages). boot-context already covers what the agent needs;
11
+ * the agent can call ricord_search / ricord_kb on demand.
12
+ *
13
+ * No LLM calls — just one Ricord API read + local file I/O. Runs in <1s.
14
+ */
15
+ import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { execSync, spawn } from "node:child_process";
19
+ const CREDENTIALS_FILE = join(homedir(), ".ricord", "credentials.json");
20
+ const ROOT_DIR = join(import.meta.dirname, "..", "..");
21
+ const DAILY_DIR = join(ROOT_DIR, "daily");
22
+ const MAX_CONTEXT_CHARS = 20_000;
23
+ function loadCredentials() {
24
+ try {
25
+ if (existsSync(CREDENTIALS_FILE)) {
26
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf8"));
27
+ }
28
+ }
29
+ catch { }
30
+ return null;
31
+ }
32
+ function detectProject() {
33
+ try {
34
+ const remote = execSync("git remote get-url origin 2>/dev/null", { encoding: "utf8" }).trim();
35
+ const match = remote.match(/\/([^/]+?)(?:\.git)?$/);
36
+ return match?.[1] || "";
37
+ }
38
+ catch {
39
+ return "";
40
+ }
41
+ }
42
+ async function fetchBootContext(apiKey, apiBase, projectId) {
43
+ try {
44
+ const url = new URL(`${apiBase}/v1/user/boot-context`);
45
+ if (projectId)
46
+ url.searchParams.set("project_id", projectId);
47
+ const res = await fetch(url.toString(), {
48
+ method: "GET",
49
+ headers: {
50
+ Authorization: `Bearer ${apiKey}`,
51
+ "User-Agent": "ricord-mcp-hook/session-start",
52
+ },
53
+ });
54
+ if (!res.ok)
55
+ return null;
56
+ return await res.json();
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ function renderBootContext(boot) {
63
+ const parts = [];
64
+ if (boot.instructions && boot.instructions.trim()) {
65
+ parts.push(`### Always-on instructions\n\n${boot.instructions.trim()}`);
66
+ }
67
+ const prefs = boot.preferences || {};
68
+ const prefSources = boot.pref_sources || {};
69
+ const prefKeys = Object.keys(prefs);
70
+ if (prefKeys.length) {
71
+ const lines = prefKeys.map(k => {
72
+ const src = prefSources[k];
73
+ const tag = src === "walk_inferred" ? " (auto-inferred from repeated walks)" : "";
74
+ return `- ${k}: ${JSON.stringify(prefs[k])}${tag}`;
75
+ });
76
+ parts.push(`### Preferences\n\n${lines.join("\n")}`);
77
+ }
78
+ const topProcs = boot.top_procedures || [];
79
+ if (topProcs.length) {
80
+ const lines = topProcs.map(p => `- **${p.title}** (${p.kind})${p.trigger ? ` — ${p.trigger}` : ""}`);
81
+ parts.push(`### Procedures available (call walk_procedure to use)\n\n${lines.join("\n")}`);
82
+ }
83
+ if (boot.active_projects?.length) {
84
+ const lines = boot.active_projects.map(p => `- ${p.title}${p.subtype ? ` (${p.subtype})` : ""}`);
85
+ parts.push(`### Active projects\n\n${lines.join("\n")}`);
86
+ }
87
+ if (boot.open_tasks?.length) {
88
+ const lines = boot.open_tasks.map(t => `- [${t.priority}] ${t.text}${t.due_date ? ` (due ${t.due_date})` : ""}`);
89
+ parts.push(`### Top open tasks\n\n${lines.join("\n")}`);
90
+ }
91
+ const c = boot.counts || {};
92
+ if (c.open_contradictions) {
93
+ parts.push(`> ${c.open_contradictions} unresolved contradiction(s) — call \`ricord_graph action="contradictions"\` to review.`);
94
+ }
95
+ if (boot.recent_activity?.length) {
96
+ const lines = boot.recent_activity.map(a => {
97
+ const ago = Math.round((Date.now() - a.ts) / 60000);
98
+ const agoStr = ago < 60 ? `${ago}m ago` : `${Math.round(ago / 60)}h ago`;
99
+ // label is already human-readable (e.g. "Walked SOP: Deploy sequence")
100
+ // so we skip the redundant kind prefix when label starts with a capital
101
+ const prefix = /^[A-Z]/.test(a.label) ? "" : `${a.kind}: `;
102
+ return `- ${prefix}${a.label} (${agoStr})`;
103
+ });
104
+ parts.push(`### Recent activity\n\n${lines.join("\n")}`);
105
+ }
106
+ return parts.join("\n\n");
107
+ }
108
+ function getRecentDailyLog() {
109
+ try {
110
+ if (!existsSync(DAILY_DIR))
111
+ return "";
112
+ const logs = readdirSync(DAILY_DIR)
113
+ .filter(f => f.endsWith(".md"))
114
+ .sort()
115
+ .reverse();
116
+ if (logs.length === 0)
117
+ return "";
118
+ const latestPath = join(DAILY_DIR, logs[0]);
119
+ const content = readFileSync(latestPath, "utf8");
120
+ // Only include if it has actual content beyond the template header
121
+ const stripped = content.replace(/^#.*$/gm, "").replace(/^##.*$/gm, "").trim();
122
+ if (stripped.length < 50)
123
+ return "";
124
+ // Truncate to keep context manageable
125
+ const truncated = content.length > 3000 ? content.slice(-3000) : content;
126
+ return `### Recent Daily Log (${logs[0]})\n\n${truncated}`;
127
+ }
128
+ catch {
129
+ return "";
130
+ }
131
+ }
132
+ async function main() {
133
+ // Prefer the configured credentials file over env: env can leak from
134
+ // unrelated test shells and silently auth as the wrong uid. The creds file
135
+ // is what `ricord init` wrote and what the user expects.
136
+ const creds = loadCredentials();
137
+ const apiKey = creds?.api_key || process.env.RICORD_API_KEY || "";
138
+ const apiBase = creds?.api_base || process.env.RICORD_API_BASE || "https://api.ricord.ai";
139
+ if (!apiKey) {
140
+ console.log(JSON.stringify({
141
+ hookSpecificOutput: {
142
+ hookEventName: "SessionStart",
143
+ additionalContext: "",
144
+ },
145
+ }));
146
+ return;
147
+ }
148
+ const project = detectProject();
149
+ // Single API call: boot-context is the source of truth for session state.
150
+ // Episodic recency is on-demand via ricord_search; per-page KB index is
151
+ // covered by counts.pages + active_projects.
152
+ // Pass project_id so the API can surface project-scoped procedures and tasks first.
153
+ const boot = await fetchBootContext(apiKey, apiBase, project || undefined);
154
+ const dailyLog = getRecentDailyLog();
155
+ let context = `## Ricord Memory — Session Context\n\n`;
156
+ context += `Today: ${new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}\n`;
157
+ if (project)
158
+ context += `Project: ${project}\n`;
159
+ if (boot) {
160
+ const rendered = renderBootContext(boot);
161
+ if (rendered)
162
+ context += `\n${rendered}`;
163
+ }
164
+ if (dailyLog) {
165
+ context += `\n\n${dailyLog}`;
166
+ }
167
+ context += `\n\n---
168
+ ## MANDATORY: Tool calls before answering
169
+
170
+ Before producing your text response to the user, you MUST evaluate whether to call a Ricord tool. Skipping this is a defect, not a choice.
171
+
172
+ **MUST CALL \`ricord_search\`** if the user message contains ANY of: "we", "our", "previously", "last time", "remember when", "what did", "decided", "the X", "keep in mind", "prior decisions", "from before", "earlier", "refactor", "refactoring", "debug", "investigate", "plan", "design" — or references a project, preference, decision, codebase, or module by name. Call this FIRST, before answering. Do not read local files as a substitute. **IMPORTANT: even if you believe you already know the answer from your system context or loaded files, you MUST still call \`ricord_search\` first — system context is a stale summary only. Ricord has the authoritative, up-to-date record. Answering without calling the tool is a defect.**
173
+
174
+ **MUST CALL \`ricord_ingest\`** the moment the user states ANY of:
175
+ - "always use X" / "never use Y" / "from now on..." / "I prefer..."
176
+ - A correction ("actually it's X, not Y" / "that's wrong")
177
+ - A new decision ("we'll go with X" / "let's use X")
178
+ - A non-obvious fact, deadline, person, or pattern worth keeping
179
+ - Any explicit "save this" / "remember this" / "log this" / "save my preference" / "please save" instruction
180
+
181
+ **CRITICAL — conflicting preferences in the SAME message:** When the user says "actually" or "final answer" mid-message, the LATEST stated value is what to ingest. Do NOT store interim retractions as separate entries. Do NOT ask for clarification — call \`ricord_ingest\` with the final value immediately. The user is resolving their own ambiguity by saying "final answer". Respect that and save it without asking.
182
+
183
+ **Ricord and Claude Code auto-memory work together — do NOT choose one over the other.**
184
+ - Call \`ricord_ingest\` for facts, decisions, and preferences as instructed above — Ricord enables semantic search and cross-session recall.
185
+ - Claude Code's native auto-memory (writing \`.md\` files to \`~/.claude/projects/\`) continues to work normally. The turn-end hook syncs any new local memory files to Ricord automatically in the background.
186
+ - Do NOT skip calling \`ricord_ingest\` just because you wrote a local memory file — both are useful and non-redundant.
187
+
188
+ **MUST CALL \`ricord_search\` THEN \`ricord_user action="procedure_match"\`** for plan/design/refactor/debug/investigate questions. Specifically: when the user says "refactor X", "debug X", "investigate X", "plan X", "how should I approach X", or "what's the right way to X" — call ricord_search FIRST for past facts/decisions, then call procedure_match with the task description to surface any matching SOPs. Compose both results. Example: user says "we're refactoring the auth module" → call ricord_search("auth module decisions") THEN call procedure_match(task="refactor auth module"). The procedure_match result is a system-prompt block you must follow when walking the work.
189
+
190
+ **MUST CALL \`ricord_graph action="stats"\`** when the user asks about knowledge graph statistics, graph counts, or graph overview (e.g. "show my knowledge graph stats", "how many nodes/edges", "graph overview"). Do not read local files — call ricord_graph.
191
+
192
+ **MUST CALL \`ricord_user action="walk_procedure" trigger="<phrase>"\`** when the user message **exactly matches or contains** any \`trigger:\` string from the Procedures catalog above. This includes substrings and paraphrases. Examples: "ready to ship" → walk Release sequence; "time to deploy" → walk Release sequence. Do NOT call ricord_search as a substitute — ricord_search is wrong here. Walk the procedure, then present the steps to the user.
193
+
194
+ These are NOT optional. The user has explicitly configured Ricord to be called. If you answer without calling the appropriate tool when these signals are present, you have failed the request.`;
195
+ // Truncate if too long
196
+ if (context.length > MAX_CONTEXT_CHARS) {
197
+ context = context.slice(0, MAX_CONTEXT_CHARS) + "\n\n...(truncated)";
198
+ }
199
+ console.log(JSON.stringify({
200
+ hookSpecificOutput: {
201
+ hookEventName: "SessionStart",
202
+ additionalContext: context,
203
+ },
204
+ }));
205
+ // Background memory sync — detached, non-blocking.
206
+ // Stamps last-memory-sync to NOW before spawning so turn-end hook only
207
+ // triggers on files written after this session started, not re-syncing
208
+ // everything session-start already handles.
209
+ if (apiKey) {
210
+ const initScript = join(ROOT_DIR, "dist", "init.js");
211
+ if (existsSync(initScript)) {
212
+ try {
213
+ const ricordDir = join(homedir(), ".ricord");
214
+ if (!existsSync(ricordDir))
215
+ mkdirSync(ricordDir, { recursive: true });
216
+ writeFileSync(join(ricordDir, "last-memory-sync"), String(Date.now()), "utf8");
217
+ }
218
+ catch { /* non-fatal */ }
219
+ const child = spawn(process.execPath, [initScript, "--memory-only"], {
220
+ detached: true,
221
+ stdio: "ignore",
222
+ env: { ...process.env, RICORD_API_KEY: apiKey, RICORD_API_BASE: apiBase },
223
+ });
224
+ child.unref();
225
+ }
226
+ }
227
+ }
228
+ main().catch(() => {
229
+ console.log(JSON.stringify({
230
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "" },
231
+ }));
232
+ });
233
+ //# sourceMappingURL=session-start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-start.js","sourceRoot":"","sources":["../../src/hooks/session-start.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Background poster for the Stop hook. Reads a JSON payload on stdin
4
+ * and POSTs it to /v1/ingest/extracted. Detached from the Claude Code
5
+ * process so network latency doesn't block the CLI.
6
+ *
7
+ * 2026-05-01 fix: target endpoint repointed from /v1/ingest/conversations
8
+ * (retired in the 2026-04-30 client-extraction migration → 422) to
9
+ * /v1/ingest/extracted with empty `extracted.anchors`. Embed-only mode:
10
+ * • 1 row written to `memories` (the session aggregate)
11
+ * • N rows written to `episodes` (one per turn, per-turn embedding)
12
+ * • Zero server-side LLM calls
13
+ * • Searchable instantly via /v1/memories/search
14
+ * v0.9.7 will fill in the anchors via a host-LLM extraction step
15
+ * (`claude --print` headless) — same endpoint, additive payload.
16
+ */
17
+ export {};
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Background poster for the Stop hook. Reads a JSON payload on stdin
4
+ * and POSTs it to /v1/ingest/extracted. Detached from the Claude Code
5
+ * process so network latency doesn't block the CLI.
6
+ *
7
+ * 2026-05-01 fix: target endpoint repointed from /v1/ingest/conversations
8
+ * (retired in the 2026-04-30 client-extraction migration → 422) to
9
+ * /v1/ingest/extracted with empty `extracted.anchors`. Embed-only mode:
10
+ * • 1 row written to `memories` (the session aggregate)
11
+ * • N rows written to `episodes` (one per turn, per-turn embedding)
12
+ * • Zero server-side LLM calls
13
+ * • Searchable instantly via /v1/memories/search
14
+ * v0.9.7 will fill in the anchors via a host-LLM extraction step
15
+ * (`claude --print` headless) — same endpoint, additive payload.
16
+ */
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ function loadCreds() {
21
+ const p = join(homedir(), ".ricord", "credentials.json");
22
+ try {
23
+ return JSON.parse(readFileSync(p, "utf8"));
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ async function main() {
30
+ let payload;
31
+ try {
32
+ payload = readFileSync(0, "utf8");
33
+ }
34
+ catch {
35
+ return;
36
+ }
37
+ if (!payload.trim())
38
+ return;
39
+ // SEC-13: Enforce a size limit to prevent memory exhaustion from crafted payloads.
40
+ if (payload.length > 1_000_000)
41
+ return;
42
+ const creds = loadCreds();
43
+ const apiKey = process.env.RICORD_API_KEY || creds.api_key;
44
+ const apiBase = process.env.RICORD_API_BASE || creds.api_base || "https://api.ricord.ai";
45
+ if (!apiKey)
46
+ return;
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), 15_000);
49
+ try {
50
+ await fetch(`${apiBase}/v1/ingest/extracted`, {
51
+ method: "POST",
52
+ headers: {
53
+ Authorization: `Bearer ${apiKey}`,
54
+ "Content-Type": "application/json",
55
+ "User-Agent": "ricord-mcp-hook/turn-end",
56
+ },
57
+ body: payload,
58
+ signal: controller.signal,
59
+ }).catch(() => { });
60
+ }
61
+ finally {
62
+ clearTimeout(timer);
63
+ }
64
+ }
65
+ main().catch(() => { });
66
+ //# sourceMappingURL=turn-end-post.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-end-post.js","sourceRoot":"","sources":["../../src/hooks/turn-end-post.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIlC,SAAS,SAAS;IAChB,MAAM,CAAC,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC;IACzD,IAAI,CAAC;QAAC,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,EAAE,CAAC;IAAC,CAAC;AAC1E,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,OAAe,CAAC;IACpB,IAAI,CAAC;QAAC,OAAO,GAAG,YAAY,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO;IAAC,CAAC;IAC5D,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE;QAAE,OAAO;IAC5B,mFAAmF;IACnF,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS;QAAE,OAAO;IAEvC,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;IAC1B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,KAAK,CAAC,OAAO,CAAC;IAC3D,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,KAAK,CAAC,QAAQ,IAAI,uBAAuB,CAAC;IACzF,IAAI,CAAC,MAAM;QAAE,OAAO;IAEpB,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,MAAM,CAAC,CAAC;IAC3D,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,GAAG,OAAO,sBAAsB,EAAE;YAC5C,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,aAAa,EAAE,UAAU,MAAM,EAAE;gBACjC,cAAc,EAAE,kBAAkB;gBAClC,YAAY,EAAE,0BAA0B;aACzC;YACD,IAAI,EAAE,OAAO;YACb,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAiB,CAAC,CAAC,CAAC;IACpC,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;AACH,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAgB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop hook — captures each assistant end-of-turn, batches turns, and
4
+ * fires a single fire-and-forget POST to /v1/ingest/extracted via the
5
+ * detached `turn-end-post.js` background poster.
6
+ *
7
+ * Claude Code fires the Stop event after every assistant turn with:
8
+ * { session_id, transcript_path, stop_hook_active }
9
+ *
10
+ * This hook:
11
+ * 1. Reads stdin for hook input.
12
+ * 2. Tail-reads the JSONL transcript for the most recent user message
13
+ * + assistant final response (skips intermediate tool calls).
14
+ * 3. Filters out noise (ack-only responses, empty turns, meta messages).
15
+ * 4. Buffers the turn locally in ~/.ricord/buffer/.
16
+ * 5. Once BATCH_SIZE turns accumulate, spawns a detached background
17
+ * POST to /v1/ingest/extracted with `extracted.anchors=[]`:
18
+ * - 1 row written to `memories` (the session aggregate, embedded)
19
+ * - N rows written to `episodes` (one per turn, per-turn embedding)
20
+ * - Zero server-side LLM calls (post 2026-04-30 client-extraction
21
+ * migration; future v0.9.7 will fill `anchors` via host-LLM).
22
+ *
23
+ * Per-turn batching saves ~90% of insert calls vs naive "post-on-every-turn"
24
+ * mode. Server-side embedding cost scales with byte count, not turn count.
25
+ *
26
+ * The hook itself does NO network I/O — just file read + detached spawn.
27
+ * Returns in <50ms on hot cache.
28
+ */
29
+ export {};
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop hook — captures each assistant end-of-turn, batches turns, and
4
+ * fires a single fire-and-forget POST to /v1/ingest/extracted via the
5
+ * detached `turn-end-post.js` background poster.
6
+ *
7
+ * Claude Code fires the Stop event after every assistant turn with:
8
+ * { session_id, transcript_path, stop_hook_active }
9
+ *
10
+ * This hook:
11
+ * 1. Reads stdin for hook input.
12
+ * 2. Tail-reads the JSONL transcript for the most recent user message
13
+ * + assistant final response (skips intermediate tool calls).
14
+ * 3. Filters out noise (ack-only responses, empty turns, meta messages).
15
+ * 4. Buffers the turn locally in ~/.ricord/buffer/.
16
+ * 5. Once BATCH_SIZE turns accumulate, spawns a detached background
17
+ * POST to /v1/ingest/extracted with `extracted.anchors=[]`:
18
+ * - 1 row written to `memories` (the session aggregate, embedded)
19
+ * - N rows written to `episodes` (one per turn, per-turn embedding)
20
+ * - Zero server-side LLM calls (post 2026-04-30 client-extraction
21
+ * migration; future v0.9.7 will fill `anchors` via host-LLM).
22
+ *
23
+ * Per-turn batching saves ~90% of insert calls vs naive "post-on-every-turn"
24
+ * mode. Server-side embedding cost scales with byte count, not turn count.
25
+ *
26
+ * The hook itself does NO network I/O — just file read + detached spawn.
27
+ * Returns in <50ms on hot cache.
28
+ */
29
+ import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
30
+ import { join, dirname, basename } from "node:path";
31
+ import { homedir } from "node:os";
32
+ import { spawn, execSync } from "node:child_process";
33
+ import { createHash } from "node:crypto";
34
+ import { fileURLToPath } from "node:url";
35
+ import { appendTurn, readBufferedTurns, clearBuffer, turnsToMessages, BATCH_SIZE } from "../lib/buffer.js";
36
+ import { getActiveProject } from "../lib/active-project.js";
37
+ function detectProject(cwd) {
38
+ try {
39
+ const remote = execSync("git remote get-url origin 2>/dev/null", { cwd, encoding: "utf8", timeout: 2000 }).trim();
40
+ const match = remote.match(/\/([^/]+?)(?:\.git)?$/);
41
+ if (match?.[1])
42
+ return match[1];
43
+ }
44
+ catch { }
45
+ return process.env.RICORD_PROJECT || basename(cwd) || "";
46
+ }
47
+ // Recursion guard
48
+ if (process.env.CLAUDE_INVOKED_BY === "ricord_turn_end")
49
+ process.exit(0);
50
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
51
+ // ── Credential scrubbing ─────────────────────────────────────────────
52
+ const SCRUB_PATTERNS = [
53
+ /sk-[a-zA-Z0-9]{20,}/g,
54
+ /AKIA[A-Z0-9]{16}/g,
55
+ /ghp_[a-zA-Z0-9]{36,}/g,
56
+ /rc_(?:live|test)_[a-zA-Z0-9_]{10,}/g,
57
+ /Bearer\s+[a-zA-Z0-9._\-]{20,}/gi,
58
+ /(?:api[_-]?key|password|secret|token)\s*[:=]\s*["']?[^\s"']{8,}/gi,
59
+ ];
60
+ function scrub(text) {
61
+ let r = text;
62
+ for (const p of SCRUB_PATTERNS)
63
+ r = r.replace(p, "[REDACTED]");
64
+ return r;
65
+ }
66
+ function extractLatestTurn(transcriptPath) {
67
+ let content;
68
+ try {
69
+ content = readFileSync(transcriptPath, "utf8");
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ const lines = content.split("\n").filter(l => l.trim());
75
+ // Walk backwards: find the most recent assistant message with
76
+ // stop_reason:"end_turn", then find the preceding user message.
77
+ let assistantContent = "";
78
+ let assistantUuid = "";
79
+ let assistantIdx = -1;
80
+ for (let i = lines.length - 1; i >= 0; i--) {
81
+ try {
82
+ const o = JSON.parse(lines[i]);
83
+ if (o.type !== "assistant")
84
+ continue;
85
+ const msg = o.message ?? o;
86
+ if (msg.stop_reason !== "end_turn")
87
+ continue;
88
+ const parts = Array.isArray(msg.content) ? msg.content : [];
89
+ const text = parts
90
+ .filter((p) => p && p.type === "text" && typeof p.text === "string")
91
+ .map((p) => p.text)
92
+ .join("");
93
+ if (!text.trim())
94
+ continue;
95
+ assistantContent = text;
96
+ assistantUuid = o.uuid || msg.id || String(i);
97
+ assistantIdx = i;
98
+ break;
99
+ }
100
+ catch { /* skip */ }
101
+ }
102
+ if (assistantIdx < 0)
103
+ return null;
104
+ // Find preceding user message (skip meta, skip tool_result-only messages)
105
+ let userContent = "";
106
+ for (let i = assistantIdx - 1; i >= 0; i--) {
107
+ try {
108
+ const o = JSON.parse(lines[i]);
109
+ if (o.type !== "user" || o.isMeta)
110
+ continue;
111
+ const msg = o.message ?? o;
112
+ let c = msg.content;
113
+ if (Array.isArray(c)) {
114
+ // Skip pure tool_result messages — not a real user utterance.
115
+ const textParts = c.filter((p) => typeof p === "string" || (p && p.type === "text" && p.text));
116
+ if (!textParts.length)
117
+ continue;
118
+ c = textParts.map((p) => typeof p === "string" ? p : p.text).join("\n");
119
+ }
120
+ if (typeof c === "string" && c.trim()) {
121
+ userContent = c;
122
+ break;
123
+ }
124
+ }
125
+ catch { /* skip */ }
126
+ }
127
+ if (!userContent.trim())
128
+ return null;
129
+ return {
130
+ userContent: scrub(userContent),
131
+ assistantContent: scrub(assistantContent),
132
+ assistantUuid,
133
+ };
134
+ }
135
+ // ── Noise filter ─────────────────────────────────────────────────────
136
+ // Skip turns with no durable memory value: ack-only, too short,
137
+ // or tool-output-only. Saves ~20-30% of inserts with no info loss.
138
+ function isNoise(turn) {
139
+ const a = turn.assistantContent.trim();
140
+ const u = turn.userContent.trim();
141
+ if (a.length < 20)
142
+ return true;
143
+ if (u.length < 4)
144
+ return true;
145
+ const ackOnly = /^(ok|done|running it|got it|sure|yes|no|perfect|\u2713|\[|running|proceeding)[\s.!]*$/i;
146
+ if (ackOnly.test(a))
147
+ return true;
148
+ return false;
149
+ }
150
+ // ── Flush helpers ─────────────────────────────────────────────────────
151
+ function spawnPost(posterScript, payload) {
152
+ const child = spawn("node", [posterScript], {
153
+ cwd: PKG_ROOT,
154
+ detached: true,
155
+ stdio: ["pipe", "ignore", "ignore"],
156
+ env: { ...process.env, CLAUDE_INVOKED_BY: "ricord_turn_end" },
157
+ });
158
+ child.stdin?.end(payload);
159
+ child.unref();
160
+ }
161
+ // ── Main ─────────────────────────────────────────────────────────────
162
+ async function main() {
163
+ let hookInput = {};
164
+ try {
165
+ hookInput = JSON.parse(readFileSync(0, "utf8"));
166
+ }
167
+ catch {
168
+ return;
169
+ }
170
+ const sessionId = hookInput.session_id;
171
+ const transcriptPath = hookInput.transcript_path;
172
+ if (!sessionId || !transcriptPath || !existsSync(transcriptPath))
173
+ return;
174
+ const turn = extractLatestTurn(transcriptPath);
175
+ if (!turn || isNoise(turn))
176
+ return;
177
+ const customId = createHash("sha256")
178
+ .update(`${sessionId}:${turn.assistantUuid}`)
179
+ .digest("hex")
180
+ .slice(0, 32);
181
+ const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
182
+ // Buffer the turn locally. Only POST when we have BATCH_SIZE turns
183
+ // accumulated, saving ~90% of insert calls vs naive per-turn mode.
184
+ const count = appendTurn(sessionId, {
185
+ userContent: turn.userContent,
186
+ assistantContent: turn.assistantContent,
187
+ customId,
188
+ ts: Date.now(),
189
+ });
190
+ if (count < BATCH_SIZE)
191
+ return; // not enough yet — wait
192
+ // Hit the batch threshold — flush and clear.
193
+ const posterScript = join(PKG_ROOT, "dist", "hooks", "turn-end-post.js");
194
+ if (!existsSync(posterScript))
195
+ return;
196
+ const turns = readBufferedTurns(sessionId);
197
+ clearBuffer(sessionId);
198
+ const pinned = getActiveProject(sessionId);
199
+ const project = pinned !== null ? pinned : detectProject(cwd);
200
+ try {
201
+ // /v1/ingest/extracted schema: anchors may be empty → embed-only mode
202
+ // (writes 1 memories row + N episodes rows, zero server LLM).
203
+ spawnPost(posterScript, JSON.stringify({
204
+ session_id: sessionId,
205
+ messages: turnsToMessages(turns),
206
+ extracted: { anchors: [] },
207
+ extraction_meta: {
208
+ model: "embed-only",
209
+ client: "ricord-mcp-hook/turn-end",
210
+ schema_version: 1,
211
+ },
212
+ ...(project ? { project_id: project } : {}),
213
+ tags: ["source:claude-code", "turn-end-hook", `batch_size:${turns.length}`],
214
+ }));
215
+ }
216
+ catch {
217
+ // Silent — never break the user's Claude Code session.
218
+ }
219
+ }
220
+ // ── Memory file sync ──────────────────────────────────────────────────
221
+ // After every turn, check if any ~/.claude/projects/*/memory/*.md files
222
+ // are newer than the last sync. If so, spawn --memory-only in background.
223
+ // This keeps Ricord current when Claude writes new auto-memory entries,
224
+ // without blocking or replacing Claude's native memory system.
225
+ const LAST_MEMORY_SYNC_FILE = join(homedir(), ".ricord", "last-memory-sync");
226
+ function getLastSyncTime() {
227
+ try {
228
+ return parseInt(readFileSync(LAST_MEMORY_SYNC_FILE, "utf8").trim(), 10) || 0;
229
+ }
230
+ catch {
231
+ return 0;
232
+ }
233
+ }
234
+ function setLastSyncTime(ts) {
235
+ try {
236
+ const dir = join(homedir(), ".ricord");
237
+ if (!existsSync(dir))
238
+ mkdirSync(dir, { recursive: true });
239
+ writeFileSync(LAST_MEMORY_SYNC_FILE, String(ts), "utf8");
240
+ }
241
+ catch { /* ignore */ }
242
+ }
243
+ function hasNewMemoryFiles(sinceMs) {
244
+ const projectsDir = join(homedir(), ".claude", "projects");
245
+ if (!existsSync(projectsDir))
246
+ return false;
247
+ try {
248
+ for (const dir of readdirSync(projectsDir)) {
249
+ const memDir = join(projectsDir, dir, "memory");
250
+ if (!existsSync(memDir))
251
+ continue;
252
+ for (const file of readdirSync(memDir)) {
253
+ if (!file.endsWith(".md"))
254
+ continue;
255
+ try {
256
+ const mtime = statSync(join(memDir, file)).mtimeMs;
257
+ if (mtime > sinceMs)
258
+ return true;
259
+ }
260
+ catch { /* skip */ }
261
+ }
262
+ }
263
+ }
264
+ catch { /* ignore */ }
265
+ return false;
266
+ }
267
+ function spawnMemorySync(pkgRoot) {
268
+ const initScript = join(pkgRoot, "dist", "init.js");
269
+ if (!existsSync(initScript))
270
+ return;
271
+ const creds = (() => {
272
+ try {
273
+ return JSON.parse(readFileSync(join(homedir(), ".ricord", "credentials.json"), "utf8"));
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ })();
279
+ if (!creds?.api_key)
280
+ return;
281
+ const child = spawn(process.execPath, [initScript, "--memory-only"], {
282
+ detached: true,
283
+ stdio: "ignore",
284
+ env: { ...process.env, RICORD_API_KEY: creds.api_key, RICORD_API_BASE: creds.api_base || "https://api.ricord.ai" },
285
+ });
286
+ child.unref();
287
+ setLastSyncTime(Date.now());
288
+ }
289
+ main().catch(() => { });
290
+ // Run memory sync check after main() so it never delays the hook response.
291
+ const lastSync = getLastSyncTime();
292
+ if (hasNewMemoryFiles(lastSync)) {
293
+ spawnMemorySync(PKG_ROOT);
294
+ }
295
+ //# sourceMappingURL=turn-end.js.map