threadctx-mcp 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 threadctx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # threadctx-mcp
2
2
 
3
3
  Shared memory MCP server for AI coding agents. Works identically with
4
- **Claude Code** and **Cursor** — same package, same config shape, no
5
- per-client integration work.
4
+ **Claude Code**, **Cursor**, and any MCP client — same package, same config
5
+ shape, no per-client integration work. On first start it also drops a
6
+ "check team memory" instruction into whichever agents' rule files your repo
7
+ uses (`AGENTS.md`, `CLAUDE.md`, Copilot, Windsurf, Cline, Gemini) so the
8
+ memory actually gets read, not just exposed.
6
9
 
7
10
  ## Modes
8
11
 
@@ -21,11 +24,13 @@ per-client integration work.
21
24
  ## Quick start
22
25
 
23
26
  ```bash
24
- # Local mode — nothing to configure
27
+ # Local mode — nothing to configure. Also auto-adds the "check team memory"
28
+ # instruction to your agents' rule files (AGENTS.md, CLAUDE.md, and any
29
+ # detected tool-specific files) on first start.
25
30
  npx threadctx-mcp
26
31
 
27
- # Optional: write a committable config + drop "check team memory" into your
28
- # agent's project rules (CLAUDE.md / .cursorrules). Works in local mode too.
32
+ # Optional: write a committable .threadctx.json config, or re-apply the
33
+ # project-rules block explicitly.
29
34
  npx threadctx-mcp init
30
35
 
31
36
  # Cloud mode — prints the exact MCP config block to paste
@@ -42,11 +47,40 @@ The key is read from the `THREADCTX_API_KEY` environment variable at
42
47
  runtime (set it in your MCP client's `env` block, as shown below), so
43
48
  secrets stay out of version control by construction.
44
49
 
45
- `init` also adds a small, clearly-marked block to your `CLAUDE.md` and
46
- `.cursorrules` instructing the agent to call `memory_query` before a task
47
- and `memory_write` after this is what makes the memory reliably get
48
- *read*, since MCP tools are pull-based (pass `--no-rules` to skip). Re-running
49
- `init` refreshes that block in place instead of duplicating it.
50
+ threadctx also adds a small, clearly-marked instruction to your project
51
+ rules telling the agent to call `memory_query` before a task and
52
+ `memory_write` after. It writes the two universal files every time
53
+ [`AGENTS.md`](https://agents.md) (the cross-tool standard read by Copilot,
54
+ Cursor, Windsurf, Zed, Codex, Aider, and ~24 others) and `CLAUDE.md`
55
+ (Claude Code's richer native format) — plus `.cursor/rules/threadctx.mdc`.
56
+ It then adds a tool-specific file **only when that tool's footprint is
57
+ detected in the repo**, so it never litters your project with rule files for
58
+ tools you don't use:
59
+
60
+ | Tool | File written | Written when |
61
+ |---|---|---|
62
+ | Cross-tool standard | `AGENTS.md` | always |
63
+ | Claude Code | `CLAUDE.md` | always |
64
+ | Cursor | `.cursor/rules/threadctx.mdc` | always |
65
+ | GitHub Copilot | `.github/copilot-instructions.md` | `.github/` exists |
66
+ | Windsurf | `.windsurf/rules/threadctx.md` | `.windsurf/` exists |
67
+ | Cline | `.clinerules/threadctx.md` | `.clinerules` exists |
68
+ | Gemini CLI | `GEMINI.md` | `.gemini/` exists |
69
+
70
+ Shared files (`AGENTS.md`, `CLAUDE.md`, Copilot, Gemini) get a marker-fenced
71
+ block spliced in, preserving your own content around it; dedicated files are
72
+ owned in full. **This happens automatically the first time the server starts
73
+ in a project — you don't need to run `init` for it.** Running `init` just
74
+ triggers it explicitly and prints the result; either way it's idempotent
75
+ (safe to re-run, never duplicates). Opt out entirely with
76
+ `THREADCTX_NO_AUTO_RULES=1`, or per-`init`-call with `--no-rules`.
77
+
78
+ The same instruction is also sent as part of the MCP `initialize` handshake
79
+ itself (the protocol's `instructions` field), so it reaches the model even
80
+ before any rules file exists, and for clients that don't read project-rules
81
+ files at all. The file-based rules are belt-and-suspenders on top of that,
82
+ since not every MCP client is guaranteed to surface `instructions`
83
+ prominently.
50
84
 
51
85
  ## Claude Code setup
52
86
 
@@ -91,6 +125,33 @@ Cursor Settings → Tools & MCP):
91
125
  That's it — the same package and config work in both clients because
92
126
  MCP is a portable, open protocol.
93
127
 
128
+ ## Passive capture — turn git history into memory
129
+
130
+ Memory shouldn't depend on an agent *remembering* to call `memory_write`.
131
+ `threadctx capture` reads the commits landed since its last run, uses **your
132
+ own LLM provider key** to distill the genuinely reusable decisions and
133
+ gotchas (skipping trivial commits), dedups them against what's already
134
+ stored, and writes the survivors. It's tool-agnostic — it doesn't matter
135
+ whether the work happened in Claude Code, Cursor, Copilot, or a plain editor.
136
+
137
+ ```bash
138
+ # Off by default because it calls an LLM (billed to your provider). Enable it:
139
+ export THREADCTX_CAPTURE_ENABLED=1
140
+ export ANTHROPIC_API_KEY=sk-... # or OPENAI_API_KEY
141
+
142
+ npx threadctx-mcp capture --dry-run # preview what it would store
143
+ npx threadctx-mcp capture # store them (incremental since last run)
144
+ npx threadctx-mcp capture --since=v1.2.0 --diffs # a range, with patches
145
+
146
+ # Scaffold a GitHub Action that captures every merged PR automatically:
147
+ npx threadctx-mcp capture --print-workflow > .github/workflows/threadctx-capture.yml
148
+ ```
149
+
150
+ Capture calls your LLM provider directly — nothing is routed through
151
+ threadctx's servers, so local mode keeps its "no network call beyond your own
152
+ LLM provider" promise. It is **off unless `THREADCTX_CAPTURE_ENABLED=1`** (or
153
+ a one-off `--force`), so it can never run up token cost as a side effect.
154
+
94
155
  ## Environment variables
95
156
 
96
157
  | Variable | Required | Description |
@@ -100,6 +161,11 @@ MCP is a portable, open protocol.
100
161
  | `THREADCTX_API_URL` | no | defaults to `https://threadctx.dev/api/v1`; override for self-hosting |
101
162
  | `THREADCTX_REPO` | no | overrides repo auto-detection from `git remote` |
102
163
  | `THREADCTX_DB_PATH` | no | local-mode store path; defaults to `~/.threadctx/local.json` |
164
+ | `THREADCTX_NO_AUTO_RULES` | no | set to `1` to disable auto-injecting agent rule files on server start |
165
+ | `THREADCTX_CAPTURE_ENABLED` | for `capture` | set to `1` to enable the LLM-backed `capture` command (off by default) |
166
+ | `THREADCTX_CAPTURE_PROVIDER` | no | force `anthropic` or `openai` when both keys are present |
167
+ | `THREADCTX_CAPTURE_MODEL` | no | override the extraction model (defaults: Haiku / gpt-4o-mini) |
168
+ | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` | for `capture` | your own provider key; capture calls it directly |
103
169
 
104
170
  ## Local development
105
171
 
@@ -120,17 +186,23 @@ npm run build # compiles to dist/ for publishing
120
186
  hits)`) so the same string is recognizable whether you're reading
121
187
  Claude Code's terminal output or Cursor's agent panel.
122
188
 
123
- Tool descriptions are deliberately written to bias the model toward
124
- calling `memory_query` proactively — MCP is pull-based, so the agent has
125
- to be prompted by the description to use it; it isn't automatic. For the
126
- most reliable behavior, run `npx threadctx-mcp init`, which adds an explicit
127
- "check team memory before/after each task" instruction to your `CLAUDE.md`
128
- and `.cursorrules`.
189
+ Tool descriptions are written to bias the model toward calling
190
+ `memory_query` proactively, and as of 0.3.0 the server reinforces this
191
+ two more ways with zero setup required: the MCP `initialize` response
192
+ carries the same instruction to every connecting client, and `CLAUDE.md` /
193
+ `.cursor/rules/threadctx.mdc` get it auto-injected on first start. MCP tools
194
+ are still fundamentally pull-based (no mechanism can force a tool call), but
195
+ these three layers together are the strongest guarantee we can build.
129
196
 
130
197
  ## CLI subcommands
131
198
 
132
199
  | Command | What it does |
133
200
  |---|---|
134
- | `npx threadctx-mcp` | Runs the MCP server (this is what Claude Code / Cursor launch). |
135
- | `npx threadctx-mcp init [--mode=cloud --api-key=…] [--no-rules]` | Writes `.threadctx.json` and the project-rules block. |
201
+ | `npx threadctx-mcp` | Runs the MCP server (this is what Claude Code / Cursor launch). Auto-injects project rules on first start in a project. |
202
+ | `npx threadctx-mcp init [--mode=cloud --api-key=…] [--no-rules]` | Writes `.threadctx.json` and explicitly (re-)applies the project-rules files. |
136
203
  | `npx threadctx-mcp list [--all] [--full] [--json]` | Shows what's stored in the local on-disk memory. |
204
+ | `npx threadctx-mcp capture [--dry-run] [--since=<ref>] [--max=N] [--diffs] [--model=ID] [--print-workflow]` | Distills recent git history into memories via your own LLM key. Off unless `THREADCTX_CAPTURE_ENABLED=1` (or `--force`). |
205
+
206
+ Browse, search, edit, and prune team (cloud) memory in a human dashboard at
207
+ [threadctx.dev/dashboard](https://threadctx.dev/dashboard) — sign in with your
208
+ team API key.
@@ -0,0 +1,23 @@
1
+ interface ExtractedMemory {
2
+ content: string;
3
+ tags: string[];
4
+ }
5
+ export declare function coerceMemories(raw: unknown[]): ExtractedMemory[];
6
+ interface CaptureFlags {
7
+ since?: string;
8
+ max: number;
9
+ dryRun: boolean;
10
+ diffs: boolean;
11
+ model?: string;
12
+ force: boolean;
13
+ printWorkflow: boolean;
14
+ }
15
+ export declare const WORKFLOW_TEMPLATE = "# Auto-distill merged PRs into threadctx team memory.\n# Requires two repo secrets:\n# THREADCTX_API_KEY \u2013 your threadctx cloud key (Team plan)\n# ANTHROPIC_API_KEY \u2013 or OPENAI_API_KEY; capture uses your own provider\nname: threadctx capture\n\non:\n pull_request:\n types: [closed]\n branches: [main]\n\njobs:\n capture:\n # Only on real merges, and only when the secret is present (skips on forks).\n if: github.event.pull_request.merged == true\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0 # full history so capture can see the merged commits\n - uses: actions/setup-node@v4\n with:\n node-version: 22\n - name: Distill merged commits into team memory\n env:\n THREADCTX_CAPTURE_ENABLED: '1'\n THREADCTX_MODE: cloud\n THREADCTX_API_KEY: ${{ secrets.THREADCTX_API_KEY }}\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: npx -y threadctx-mcp capture --since ${{ github.event.pull_request.base.sha }}\n";
16
+ /**
17
+ * `threadctx capture [--since=<ref>] [--max=N] [--diffs] [--dry-run] [--model=ID]`
18
+ * Distills commits landed since the last capture (or the last N commits) into
19
+ * memories using your own LLM provider key, dedups against what's stored, and
20
+ * writes the survivors to the local or cloud store.
21
+ */
22
+ export declare function runCapture(flags: CaptureFlags): Promise<void>;
23
+ export {};
@@ -0,0 +1,291 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { loadConfig } from './config.js';
5
+ import { LocalStore } from './local-store.js';
6
+ import { CloudClient } from './cloud-client.js';
7
+ import { complete, detectProvider, parseJsonArray } from './llm.js';
8
+ const US = '\x1f'; // unit separator between fields
9
+ const RS = '\x1e'; // record separator between commits
10
+ const MAX_BODY_CHARS = 1200; // bound a runaway commit body's token cost
11
+ function git(args) {
12
+ return execFileSync('git', args, { cwd: process.cwd(), stdio: ['ignore', 'pipe', 'ignore'] })
13
+ .toString()
14
+ .trim();
15
+ }
16
+ function isGitRepo() {
17
+ try {
18
+ git(['rev-parse', '--is-inside-work-tree']);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ function isAncestor(sha) {
26
+ try {
27
+ execFileSync('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {
28
+ cwd: process.cwd(),
29
+ stdio: 'ignore',
30
+ });
31
+ return true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ // Per-repo capture state (last commit we've already distilled) lives next to the
38
+ // local store, keyed by repo slug, so re-running capture is incremental and
39
+ // idempotent — you only ever process commits landed since the last run.
40
+ function markerPath(config) {
41
+ const slug = config.repo.replace(/[^a-zA-Z0-9._-]+/g, '__');
42
+ return join(dirname(config.dbPath), 'capture', `${slug}.json`);
43
+ }
44
+ function readLastSha(config) {
45
+ const path = markerPath(config);
46
+ if (!existsSync(path))
47
+ return null;
48
+ try {
49
+ return JSON.parse(readFileSync(path, 'utf-8')).lastSha ?? null;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function writeLastSha(config, sha) {
56
+ const path = markerPath(config);
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ writeFileSync(path, JSON.stringify({ lastSha: sha, updatedAt: new Date().toISOString() }, null, 2) + '\n');
59
+ }
60
+ function collectCommits(range, max) {
61
+ const format = ['%H', '%ad', '%s', '%b'].join(US) + RS;
62
+ const args = ['log', '--no-merges', '--date=short', `--pretty=format:${format}`];
63
+ if (range)
64
+ args.push(range);
65
+ else
66
+ args.push(`-n`, String(max));
67
+ const raw = git(args);
68
+ if (!raw)
69
+ return [];
70
+ return raw
71
+ .split(RS)
72
+ .map((rec) => rec.replace(/^\s+/, ''))
73
+ .filter(Boolean)
74
+ .map((rec) => {
75
+ const [hash, date, subject, body = ''] = rec.split(US);
76
+ return { hash, date, subject, body: body.trim().slice(0, MAX_BODY_CHARS) };
77
+ })
78
+ .filter((c) => c.hash && c.subject);
79
+ }
80
+ const SYSTEM_PROMPT = [
81
+ 'You extract durable, reusable engineering knowledge from git history for a team memory system.',
82
+ 'A good memory is a non-obvious decision, a fix for a tricky bug, a gotcha, a constraint, or an',
83
+ 'architectural choice that would save a teammate real time later. Write each as a SELF-CONTAINED',
84
+ 'note (2-5 sentences) with enough context to be understood months later by someone who was not there.',
85
+ '',
86
+ 'STRICT RULES:',
87
+ '- Skip trivial/mechanical commits: version bumps, formatting, lint, typo fixes, routine dependency',
88
+ ' bumps, "wip", merge commits, and anything with no reusable lesson.',
89
+ '- Do NOT restate the diff; capture the WHY and the lesson, not the change.',
90
+ '- Do NOT duplicate anything in the "Already stored" list; skip near-duplicates.',
91
+ '- Prefer fewer, higher-quality notes. Returning an empty array is correct when nothing qualifies.',
92
+ '- Output ONLY a JSON array. Each item: {"content": string, "tags": string[] (1-4 short tags)}.',
93
+ ].join('\n');
94
+ function buildUserPrompt(repo, commits, existing, includeDiffs) {
95
+ const existingBlock = existing.length
96
+ ? existing.map((c) => `- ${c.replace(/\s+/g, ' ').slice(0, 240)}`).join('\n')
97
+ : '(none)';
98
+ const commitBlock = commits
99
+ .map((c) => {
100
+ const diff = includeDiffs ? diffFor(c.hash) : '';
101
+ return [
102
+ `commit ${c.hash.slice(0, 10)} (${c.date})`,
103
+ `subject: ${c.subject}`,
104
+ c.body ? `body:\n${c.body}` : '',
105
+ diff ? `diff (truncated):\n${diff}` : '',
106
+ ]
107
+ .filter(Boolean)
108
+ .join('\n');
109
+ })
110
+ .join('\n\n---\n\n');
111
+ return [
112
+ `Repository: ${repo}`,
113
+ '',
114
+ 'Already stored (do not duplicate these):',
115
+ existingBlock,
116
+ '',
117
+ `New commits to distill (${commits.length}):`,
118
+ '',
119
+ commitBlock,
120
+ '',
121
+ 'Return the JSON array now.',
122
+ ].join('\n');
123
+ }
124
+ function diffFor(hash) {
125
+ try {
126
+ // Names + patch, but bounded: full diffs blow up tokens and can leak secrets,
127
+ // so --diffs is opt-in and each commit's patch is hard-capped.
128
+ return git(['show', '--no-color', '--stat', '--patch', '--format=', hash]).slice(0, 3000);
129
+ }
130
+ catch {
131
+ return '';
132
+ }
133
+ }
134
+ export function coerceMemories(raw) {
135
+ const out = [];
136
+ for (const item of raw) {
137
+ if (!item || typeof item !== 'object')
138
+ continue;
139
+ const obj = item;
140
+ const content = typeof obj.content === 'string' ? obj.content.trim() : '';
141
+ if (content.length < 20)
142
+ continue; // reject empty/degenerate notes
143
+ const tags = Array.isArray(obj.tags)
144
+ ? obj.tags.filter((t) => typeof t === 'string').map((t) => t.trim().slice(0, 50)).slice(0, 4)
145
+ : [];
146
+ out.push({ content: content.slice(0, 4000), tags });
147
+ }
148
+ return out;
149
+ }
150
+ // A ready-to-commit GitHub Actions workflow that runs capture automatically when
151
+ // a PR merges — this is what makes capture a passive, always-on team habit rather
152
+ // than a manual chore. Printed by `threadctx capture --print-workflow` so a team
153
+ // can scaffold it in one line:
154
+ // npx threadctx-mcp capture --print-workflow > .github/workflows/threadctx-capture.yml
155
+ // It captures exactly the merged PR's commits (--since the base sha), and is
156
+ // itself gated by THREADCTX_CAPTURE_ENABLED so it never runs unless configured.
157
+ export const WORKFLOW_TEMPLATE = `# Auto-distill merged PRs into threadctx team memory.
158
+ # Requires two repo secrets:
159
+ # THREADCTX_API_KEY – your threadctx cloud key (Team plan)
160
+ # ANTHROPIC_API_KEY – or OPENAI_API_KEY; capture uses your own provider
161
+ name: threadctx capture
162
+
163
+ on:
164
+ pull_request:
165
+ types: [closed]
166
+ branches: [main]
167
+
168
+ jobs:
169
+ capture:
170
+ # Only on real merges, and only when the secret is present (skips on forks).
171
+ if: github.event.pull_request.merged == true
172
+ runs-on: ubuntu-latest
173
+ steps:
174
+ - uses: actions/checkout@v4
175
+ with:
176
+ fetch-depth: 0 # full history so capture can see the merged commits
177
+ - uses: actions/setup-node@v4
178
+ with:
179
+ node-version: 22
180
+ - name: Distill merged commits into team memory
181
+ env:
182
+ THREADCTX_CAPTURE_ENABLED: '1'
183
+ THREADCTX_MODE: cloud
184
+ THREADCTX_API_KEY: \${{ secrets.THREADCTX_API_KEY }}
185
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
186
+ run: npx -y threadctx-mcp capture --since \${{ github.event.pull_request.base.sha }}
187
+ `;
188
+ // The master on/off switch for the only LLM-billed feature. Accepts the usual
189
+ // truthy spellings; anything else (including unset) means OFF.
190
+ function captureEnabled() {
191
+ const v = (process.env.THREADCTX_CAPTURE_ENABLED ?? '').trim().toLowerCase();
192
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
193
+ }
194
+ /**
195
+ * `threadctx capture [--since=<ref>] [--max=N] [--diffs] [--dry-run] [--model=ID]`
196
+ * Distills commits landed since the last capture (or the last N commits) into
197
+ * memories using your own LLM provider key, dedups against what's stored, and
198
+ * writes the survivors to the local or cloud store.
199
+ */
200
+ export async function runCapture(flags) {
201
+ // Print-only: scaffold the CI workflow. Costs nothing, so it runs before any
202
+ // enable-gate or git checks.
203
+ if (flags.printWorkflow) {
204
+ process.stdout.write(WORKFLOW_TEMPLATE);
205
+ return;
206
+ }
207
+ // Capture is the only feature that calls an LLM, so it's OFF by default and
208
+ // must be explicitly switched on. This guarantees zero token spend unless a
209
+ // human/CI opts in — nothing here ever runs an LLM as a side effect of normal
210
+ // MCP usage. Enable with THREADCTX_CAPTURE_ENABLED=1 (or --force for a one-off).
211
+ if (!captureEnabled() && !flags.force) {
212
+ console.error('[threadctx] `capture` is off by default because it calls an LLM (billed to your own');
213
+ console.error('[threadctx] provider key). Turn it on explicitly when you want it:');
214
+ console.error('[threadctx] export THREADCTX_CAPTURE_ENABLED=1 # persistent on/off switch');
215
+ console.error('[threadctx] npx threadctx-mcp capture --dry-run # or add --force for a one-off run');
216
+ process.exit(1);
217
+ }
218
+ if (!isGitRepo()) {
219
+ console.error('[threadctx] `capture` must run inside a git repository (none found here).');
220
+ process.exit(1);
221
+ }
222
+ const provider = detectProvider(flags.model);
223
+ if (!provider) {
224
+ console.error('[threadctx] capture needs your own LLM provider key to distill commits.');
225
+ console.error('[threadctx] Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your environment and re-run.');
226
+ console.error('[threadctx] (Nothing is sent to threadctx servers — capture calls your provider directly.)');
227
+ process.exit(1);
228
+ }
229
+ const config = loadConfig();
230
+ // Resolve the commit range: explicit --since wins, else incremental from the
231
+ // last captured SHA (if it's still an ancestor of HEAD), else last --max.
232
+ let range = null;
233
+ if (flags.since) {
234
+ range = `${flags.since}..HEAD`;
235
+ }
236
+ else {
237
+ const lastSha = readLastSha(config);
238
+ if (lastSha && isAncestor(lastSha))
239
+ range = `${lastSha}..HEAD`;
240
+ }
241
+ const commits = collectCommits(range, flags.max);
242
+ if (commits.length === 0) {
243
+ console.log('[threadctx] No new commits to capture. You are up to date.');
244
+ return;
245
+ }
246
+ const head = git(['rev-parse', 'HEAD']);
247
+ // Existing memories for dedup context (best-effort in cloud mode).
248
+ const localStore = config.mode === 'cloud' && config.apiKey ? null : new LocalStore(config.dbPath);
249
+ const cloudClient = config.mode === 'cloud' && config.apiKey
250
+ ? new CloudClient(config.apiUrl, config.apiKey, config.actorId)
251
+ : null;
252
+ const existing = localStore
253
+ ? localStore.list(config.repo).map((m) => m.content)
254
+ : (await cloudClient.recent(config.repo, 100)).map((m) => m.content);
255
+ console.log(`[threadctx] Distilling ${commits.length} commit${commits.length === 1 ? '' : 's'} for ${config.repo} ` +
256
+ `via ${provider.name} (${provider.model})…`);
257
+ const responseText = await complete(provider, SYSTEM_PROMPT, buildUserPrompt(config.repo, commits, existing, flags.diffs));
258
+ const memories = coerceMemories(parseJsonArray(responseText));
259
+ if (memories.length === 0) {
260
+ console.log('[threadctx] Nothing worth remembering in these commits (that is a fine outcome).');
261
+ if (!flags.dryRun)
262
+ writeLastSha(config, head);
263
+ return;
264
+ }
265
+ console.log(`\n[threadctx] ${memories.length} candidate ${memories.length === 1 ? 'memory' : 'memories'}:\n`);
266
+ memories.forEach((m, i) => {
267
+ console.log(`${i + 1}. ${m.content}`);
268
+ if (m.tags.length)
269
+ console.log(` tags: ${m.tags.join(', ')}`);
270
+ console.log('');
271
+ });
272
+ if (flags.dryRun) {
273
+ console.log('[threadctx] --dry-run: nothing written. Re-run without --dry-run to store these.');
274
+ return;
275
+ }
276
+ let stored = 0;
277
+ for (const m of memories) {
278
+ try {
279
+ if (localStore)
280
+ localStore.write(config.repo, m.content, m.tags);
281
+ else
282
+ await cloudClient.write(config.repo, m.content, m.tags);
283
+ stored += 1;
284
+ }
285
+ catch (err) {
286
+ console.error(`[threadctx] Failed to store a memory: ${err instanceof Error ? err.message : String(err)}`);
287
+ }
288
+ }
289
+ writeLastSha(config, head);
290
+ console.log(`[threadctx] Stored ${stored} ${stored === 1 ? 'memory' : 'memories'} to ${localStore ? 'the local store' : 'threadctx cloud'}. Your team can now query these.`);
291
+ }
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { loadConfig } from './config.js';
5
5
  import { LocalStore } from './local-store.js';
6
+ import { applyRules } from './rules.js';
7
+ import { runCapture } from './capture.js';
6
8
  import { startServer } from './server.js';
7
9
  const [, , command, ...rest] = process.argv;
8
10
  function parseFlags(args) {
@@ -19,53 +21,6 @@ function parseFlags(args) {
19
21
  }
20
22
  return flags;
21
23
  }
22
- // The exact instruction block we drop into an agent's project rules. MCP is
23
- // pull-based — the model only calls memory_query/memory_write if something tells
24
- // it to — so this is the single highest-leverage thing for making the memory
25
- // actually get read. Fenced by markers so re-running `init` updates in place
26
- // instead of appending duplicates.
27
- const RULES_START = '<!-- threadctx:start (managed — edit above/below, not between) -->';
28
- const RULES_END = '<!-- threadctx:end -->';
29
- const RULES_BODY = [
30
- '## Team memory (threadctx)',
31
- '',
32
- '- **Before** starting any non-trivial task, call `memory_query` with a short',
33
- ' description of what you are about to do. Check for prior decisions, fixes,',
34
- ' and gotchas on this repo before writing code — not after.',
35
- '- **After** resolving a non-obvious bug, making an architectural decision, or',
36
- ' learning something that would save a teammate time, call `memory_write` to',
37
- ' save it. Write it so a future reader has full context.',
38
- ].join('\n');
39
- /**
40
- * Insert (or refresh) the threadctx rules block in a project rules file,
41
- * idempotently. Creates the file if missing. Returns what happened so the
42
- * caller can report it honestly.
43
- */
44
- function upsertRulesBlock(filePath) {
45
- const block = `${RULES_START}\n${RULES_BODY}\n${RULES_END}\n`;
46
- if (!existsSync(filePath)) {
47
- writeFileSync(filePath, block);
48
- return 'created';
49
- }
50
- const existing = readFileSync(filePath, 'utf-8');
51
- const startIdx = existing.indexOf(RULES_START);
52
- if (startIdx !== -1) {
53
- const endIdx = existing.indexOf(RULES_END, startIdx);
54
- if (endIdx !== -1) {
55
- const before = existing.slice(0, startIdx);
56
- const after = existing.slice(endIdx + RULES_END.length);
57
- const next = `${before}${block.trimEnd()}${after}`;
58
- if (next === existing)
59
- return 'unchanged';
60
- writeFileSync(filePath, next);
61
- return 'updated';
62
- }
63
- }
64
- // No managed block yet — append one, keeping the user's existing content.
65
- const sep = existing.endsWith('\n') ? '\n' : '\n\n';
66
- writeFileSync(filePath, `${existing}${sep}${block}`);
67
- return 'updated';
68
- }
69
24
  function runInit(args) {
70
25
  const flags = parseFlags(args);
71
26
  const mode = flags.mode === 'cloud' ? 'cloud' : 'local';
@@ -87,14 +42,17 @@ function runInit(args) {
87
42
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
88
43
  console.log(`✅ Wrote ${configPath} (mode: ${mode}) — safe to commit, contains no secret.`);
89
44
  // Drop the "always check team memory" instruction into the agent's project
90
- // rules so the tools actually get used. Opt out with --no-rules.
45
+ // rules so the tools actually get used, even for clients that don't surface
46
+ // MCP's `initialize.instructions` prominently. Opt out with --no-rules. The
47
+ // server also does this automatically on every start (see server.ts) — this
48
+ // just lets you trigger it explicitly and see the result immediately.
91
49
  if (flags['no-rules'] !== true) {
92
- for (const rulesFile of ['CLAUDE.md', '.cursorrules']) {
93
- const rulesPath = join(process.cwd(), rulesFile);
94
- const result = upsertRulesBlock(rulesPath);
95
- const verb = result === 'created' ? 'Created' : result === 'updated' ? 'Updated' : 'Already current in';
96
- console.log(`✅ ${verb} ${rulesFile} — tells your agent to check team memory each task.`);
50
+ const describe = (r) => (r === 'created' ? 'Created' : r === 'updated' ? 'Updated' : 'Already current');
51
+ for (const rule of applyRules(process.cwd())) {
52
+ console.log(`✅ ${describe(rule.result)}: ${rule.label} — tells your agent to check team memory each task.`);
97
53
  }
54
+ console.log(' (AGENTS.md + CLAUDE.md are always written; Copilot/Windsurf/Cline/Gemini files are added only');
55
+ console.log(' when that tool is detected in the repo. Opt out entirely with --no-rules.)');
98
56
  }
99
57
  console.log('');
100
58
  if (mode === 'cloud') {
@@ -195,6 +153,20 @@ async function main() {
195
153
  runList(rest);
196
154
  return;
197
155
  }
156
+ if (command === 'capture') {
157
+ const flags = parseFlags(rest);
158
+ const parsedMax = typeof flags.max === 'string' ? parseInt(flags.max, 10) : NaN;
159
+ await runCapture({
160
+ since: typeof flags.since === 'string' ? flags.since : undefined,
161
+ max: Number.isFinite(parsedMax) && parsedMax > 0 ? parsedMax : 30,
162
+ dryRun: flags['dry-run'] === true,
163
+ diffs: flags.diffs === true,
164
+ model: typeof flags.model === 'string' ? flags.model : undefined,
165
+ force: flags.force === true,
166
+ printWorkflow: flags['print-workflow'] === true,
167
+ });
168
+ return;
169
+ }
198
170
  // No subcommand: this is what Claude Code / Cursor actually launch as the
199
171
  // MCP server process (they invoke `npx threadctx-mcp` with no arguments).
200
172
  await startServer();
@@ -9,6 +9,17 @@ export interface CloudQueryResponse {
9
9
  num_candidates: number;
10
10
  };
11
11
  }
12
+ export interface CloudMemory {
13
+ id: string;
14
+ repo: string;
15
+ content: string;
16
+ tags: string[];
17
+ created_at: string;
18
+ }
19
+ export interface CloudListResponse {
20
+ memories: CloudMemory[];
21
+ total: number;
22
+ }
12
23
  /**
13
24
  * Thin HTTP client for the threadctx cloud API. Talks to either the
14
25
  * hosted threadctx.dev service or a self-hosted deployment (set
@@ -19,7 +30,14 @@ export declare class CloudClient {
19
30
  private apiKey;
20
31
  private actorId?;
21
32
  constructor(apiUrl: string, apiKey: string, actorId?: string | undefined);
33
+ private authHeaders;
22
34
  private request;
23
35
  write(repo: string, content: string, tags: string[]): Promise<CloudWriteResponse>;
24
36
  query(repo: string, taskDescription: string, maxResults: number): Promise<CloudQueryResponse>;
37
+ /**
38
+ * Most-recent memories for a repo, used by `threadctx capture` to dedup new
39
+ * extractions against what's already stored. Best-effort: on any error (e.g. an
40
+ * older server without the list endpoint) it returns [] so capture still runs.
41
+ */
42
+ recent(repo: string, limit?: number): Promise<CloudMemory[]>;
25
43
  }
@@ -12,7 +12,7 @@ export class CloudClient {
12
12
  this.apiKey = apiKey;
13
13
  this.actorId = actorId;
14
14
  }
15
- async request(path, body) {
15
+ authHeaders() {
16
16
  const headers = {
17
17
  'Content-Type': 'application/json',
18
18
  Authorization: `Bearer ${this.apiKey}`,
@@ -20,9 +20,12 @@ export class CloudClient {
20
20
  // Anonymous per-developer id for seat accounting (see config.resolveActorId).
21
21
  if (this.actorId)
22
22
  headers['X-Threadctx-Actor'] = this.actorId;
23
+ return headers;
24
+ }
25
+ async request(path, body) {
23
26
  const res = await fetch(`${this.apiUrl}${path}`, {
24
27
  method: 'POST',
25
- headers,
28
+ headers: this.authHeaders(),
26
29
  body: JSON.stringify(body),
27
30
  });
28
31
  if (!res.ok) {
@@ -47,4 +50,22 @@ export class CloudClient {
47
50
  max_results: maxResults,
48
51
  });
49
52
  }
53
+ /**
54
+ * Most-recent memories for a repo, used by `threadctx capture` to dedup new
55
+ * extractions against what's already stored. Best-effort: on any error (e.g. an
56
+ * older server without the list endpoint) it returns [] so capture still runs.
57
+ */
58
+ async recent(repo, limit = 100) {
59
+ try {
60
+ const url = `${this.apiUrl}/memory/list?repo=${encodeURIComponent(repo)}&limit=${limit}`;
61
+ const res = await fetch(url, { method: 'GET', headers: this.authHeaders() });
62
+ if (!res.ok)
63
+ return [];
64
+ const json = (await res.json());
65
+ return json.memories ?? [];
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ }
50
71
  }
package/dist/llm.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export type ProviderName = 'anthropic' | 'openai';
2
+ export interface LlmProvider {
3
+ name: ProviderName;
4
+ apiKey: string;
5
+ model: string;
6
+ }
7
+ /**
8
+ * Pick a provider from the environment. Explicit provider via
9
+ * THREADCTX_CAPTURE_PROVIDER wins; otherwise Anthropic is preferred when both
10
+ * keys are present. Returns null when no usable key is configured.
11
+ */
12
+ export declare function detectProvider(explicitModel?: string): LlmProvider | null;
13
+ /** Send a system+user prompt and return the model's raw text response. */
14
+ export declare function complete(provider: LlmProvider, system: string, user: string): Promise<string>;
15
+ /**
16
+ * Parse the first top-level JSON array out of a model response, tolerating
17
+ * ```json fences and surrounding prose. Returns [] if nothing parseable is found
18
+ * rather than throwing, so a chatty model can never crash a capture run.
19
+ */
20
+ export declare function parseJsonArray(text: string): unknown[];
package/dist/llm.js ADDED
@@ -0,0 +1,109 @@
1
+ // Minimal, dependency-free LLM client used by `threadctx capture` to distill
2
+ // git history into memories. Deliberately uses each provider's raw HTTP API via
3
+ // global fetch (Node 18+) rather than an SDK, to keep the `npx threadctx-mcp`
4
+ // install a single small package with no transitive AI-SDK weight.
5
+ //
6
+ // The key is the *user's own* provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY),
7
+ // read from the environment — capture never routes text through threadctx's
8
+ // servers, so local mode still makes no network call beyond the LLM provider the
9
+ // user already trusts with their code.
10
+ const DEFAULT_MODELS = {
11
+ // Small, fast, cheap models — extraction is a cheap classification-ish task and
12
+ // capture may run over many commits, so we default low-cost and let --model or
13
+ // THREADCTX_CAPTURE_MODEL override for higher-fidelity runs.
14
+ anthropic: 'claude-haiku-4-5-20251001',
15
+ openai: 'gpt-4o-mini',
16
+ };
17
+ /**
18
+ * Pick a provider from the environment. Explicit provider via
19
+ * THREADCTX_CAPTURE_PROVIDER wins; otherwise Anthropic is preferred when both
20
+ * keys are present. Returns null when no usable key is configured.
21
+ */
22
+ export function detectProvider(explicitModel) {
23
+ const forced = process.env.THREADCTX_CAPTURE_PROVIDER;
24
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
25
+ const openaiKey = process.env.OPENAI_API_KEY;
26
+ const model = explicitModel || process.env.THREADCTX_CAPTURE_MODEL;
27
+ const build = (name, apiKey) => ({
28
+ name,
29
+ apiKey,
30
+ model: model || DEFAULT_MODELS[name],
31
+ });
32
+ if (forced === 'anthropic' && anthropicKey)
33
+ return build('anthropic', anthropicKey);
34
+ if (forced === 'openai' && openaiKey)
35
+ return build('openai', openaiKey);
36
+ if (anthropicKey)
37
+ return build('anthropic', anthropicKey);
38
+ if (openaiKey)
39
+ return build('openai', openaiKey);
40
+ return null;
41
+ }
42
+ /** Send a system+user prompt and return the model's raw text response. */
43
+ export async function complete(provider, system, user) {
44
+ return provider.name === 'anthropic'
45
+ ? completeAnthropic(provider, system, user)
46
+ : completeOpenai(provider, system, user);
47
+ }
48
+ async function completeAnthropic(provider, system, user) {
49
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ 'x-api-key': provider.apiKey,
54
+ 'anthropic-version': '2023-06-01',
55
+ },
56
+ body: JSON.stringify({
57
+ model: provider.model,
58
+ max_tokens: 2048,
59
+ system,
60
+ messages: [{ role: 'user', content: user }],
61
+ }),
62
+ });
63
+ if (!res.ok)
64
+ throw new Error(`Anthropic API error (${res.status}): ${await res.text().catch(() => '')}`);
65
+ const json = (await res.json());
66
+ return (json.content ?? [])
67
+ .filter((b) => b.type === 'text')
68
+ .map((b) => b.text ?? '')
69
+ .join('');
70
+ }
71
+ async function completeOpenai(provider, system, user) {
72
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ Authorization: `Bearer ${provider.apiKey}`,
77
+ },
78
+ body: JSON.stringify({
79
+ model: provider.model,
80
+ messages: [
81
+ { role: 'system', content: system },
82
+ { role: 'user', content: user },
83
+ ],
84
+ }),
85
+ });
86
+ if (!res.ok)
87
+ throw new Error(`OpenAI API error (${res.status}): ${await res.text().catch(() => '')}`);
88
+ const json = (await res.json());
89
+ return json.choices?.[0]?.message?.content ?? '';
90
+ }
91
+ /**
92
+ * Parse the first top-level JSON array out of a model response, tolerating
93
+ * ```json fences and surrounding prose. Returns [] if nothing parseable is found
94
+ * rather than throwing, so a chatty model can never crash a capture run.
95
+ */
96
+ export function parseJsonArray(text) {
97
+ const cleaned = text.replace(/```(?:json)?/gi, '').trim();
98
+ const start = cleaned.indexOf('[');
99
+ const end = cleaned.lastIndexOf(']');
100
+ if (start === -1 || end === -1 || end < start)
101
+ return [];
102
+ try {
103
+ const parsed = JSON.parse(cleaned.slice(start, end + 1));
104
+ return Array.isArray(parsed) ? parsed : [];
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ }
@@ -0,0 +1,16 @@
1
+ export declare const RULES_BODY: string;
2
+ export type RuleResult = 'created' | 'updated' | 'unchanged';
3
+ export interface AppliedRule {
4
+ key: string;
5
+ label: string;
6
+ result: RuleResult;
7
+ }
8
+ /**
9
+ * Idempotently ensure every applicable agent's project rules contain the "check
10
+ * team memory" instruction. Universal files (AGENTS.md, CLAUDE.md) and Cursor
11
+ * are always written; tool-specific files (Copilot, Windsurf, Cline, Gemini) are
12
+ * written only when that tool's footprint is detected in the repo, so we never
13
+ * scatter rule files for tools the team doesn't use. Safe to call on every
14
+ * server start — repeat calls are no-ops once each file is already current.
15
+ */
16
+ export declare function applyRules(projectRoot: string): AppliedRule[];
package/dist/rules.js ADDED
@@ -0,0 +1,158 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ // The exact instruction dropped into an agent's project rules. MCP is
4
+ // pull-based — the model only calls memory_query/memory_write if something
5
+ // tells it to — so this is the highest-leverage thing for making the memory
6
+ // actually get read. Also sent verbatim as the MCP `initialize` response's
7
+ // `instructions` field (see server.ts) so it reaches every client even
8
+ // before any file gets written.
9
+ export const RULES_BODY = [
10
+ '## Team memory (threadctx)',
11
+ '',
12
+ '- **Before** starting any non-trivial task, call `memory_query` with a short',
13
+ ' description of what you are about to do. Check for prior decisions, fixes,',
14
+ ' and gotchas on this repo before writing code — not after.',
15
+ '- **After** resolving a non-obvious bug, making an architectural decision, or',
16
+ ' learning something that would save a teammate time, call `memory_write` to',
17
+ ' save it. Write it so a future reader has full context.',
18
+ ].join('\n');
19
+ const MARKER_START = '<!-- threadctx:start (managed — edit above/below, not between) -->';
20
+ const MARKER_END = '<!-- threadctx:end -->';
21
+ // The marker-fenced block, shared by every target. For "shared" files we splice
22
+ // just this block in and leave the rest of the file alone; for "dedicated" files
23
+ // (ones whose filename is ours) the whole file is this block, optionally under a
24
+ // tool-specific frontmatter header.
25
+ const managedBlock = `${MARKER_START}\n${RULES_BODY}\n${MARKER_END}\n`;
26
+ /**
27
+ * Insert (or refresh) the threadctx rules block in a shared, general-purpose
28
+ * rules file (CLAUDE.md, AGENTS.md, Copilot/Gemini instructions), idempotently.
29
+ * Creates the file if missing, preserves any of the user's own content outside
30
+ * the marker-fenced block.
31
+ */
32
+ function upsertMarkedBlock(filePath) {
33
+ if (!existsSync(filePath)) {
34
+ mkdirSync(dirname(filePath), { recursive: true });
35
+ writeFileSync(filePath, managedBlock);
36
+ return 'created';
37
+ }
38
+ const existing = readFileSync(filePath, 'utf-8');
39
+ const startIdx = existing.indexOf(MARKER_START);
40
+ if (startIdx !== -1) {
41
+ const endIdx = existing.indexOf(MARKER_END, startIdx);
42
+ if (endIdx !== -1) {
43
+ const before = existing.slice(0, startIdx);
44
+ const after = existing.slice(endIdx + MARKER_END.length);
45
+ const next = `${before}${managedBlock.trimEnd()}${after}`;
46
+ if (next === existing)
47
+ return 'unchanged';
48
+ writeFileSync(filePath, next);
49
+ return 'updated';
50
+ }
51
+ }
52
+ // No managed block yet — append one, keeping the user's existing content.
53
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
54
+ writeFileSync(filePath, `${existing}${sep}${managedBlock}`);
55
+ return 'updated';
56
+ }
57
+ /**
58
+ * Insert (or refresh) a dedicated rule file whose entire contents we own (e.g.
59
+ * .cursor/rules/threadctx.mdc). Because the filename is ours alone, on a
60
+ * mismatch we simply rewrite it in full rather than doing marker surgery.
61
+ * `frontmatter` lines (if any) are written above the managed block — some tools
62
+ * require a small YAML header to mark a rule as always-active.
63
+ */
64
+ function upsertDedicatedFile(filePath, frontmatter) {
65
+ const header = frontmatter.length ? `---\n${frontmatter.join('\n')}\n---\n\n` : '';
66
+ const content = `${header}${managedBlock}`;
67
+ if (!existsSync(filePath)) {
68
+ mkdirSync(dirname(filePath), { recursive: true });
69
+ writeFileSync(filePath, content);
70
+ return 'created';
71
+ }
72
+ if (readFileSync(filePath, 'utf-8') === content)
73
+ return 'unchanged';
74
+ writeFileSync(filePath, content);
75
+ return 'updated';
76
+ }
77
+ const dirExists = (root, ...segs) => existsSync(join(root, ...segs));
78
+ const pathExists = (root, ...segs) => existsSync(join(root, ...segs));
79
+ const TARGETS = [
80
+ // --- Universal: always written, broad coverage, low noise ---
81
+ {
82
+ key: 'agents',
83
+ label: 'AGENTS.md',
84
+ relPath: ['AGENTS.md'],
85
+ kind: 'shared',
86
+ },
87
+ {
88
+ key: 'claude',
89
+ label: 'CLAUDE.md',
90
+ relPath: ['CLAUDE.md'],
91
+ kind: 'shared',
92
+ },
93
+ {
94
+ // Kept always-on (not footprint-gated) to preserve prior behavior: Cursor is
95
+ // a primary target and its global-MCP users may not have a project .cursor/.
96
+ key: 'cursor',
97
+ label: '.cursor/rules/threadctx.mdc',
98
+ relPath: ['.cursor', 'rules', 'threadctx.mdc'],
99
+ kind: 'dedicated',
100
+ frontmatter: ['description: Use threadctx shared team memory', 'alwaysApply: true'],
101
+ },
102
+ // --- Footprint-detected: only written when the tool is clearly in use ---
103
+ {
104
+ key: 'copilot',
105
+ label: '.github/copilot-instructions.md',
106
+ relPath: ['.github', 'copilot-instructions.md'],
107
+ kind: 'shared',
108
+ detect: (root) => dirExists(root, '.github'),
109
+ },
110
+ {
111
+ key: 'windsurf',
112
+ label: '.windsurf/rules/threadctx.md',
113
+ relPath: ['.windsurf', 'rules', 'threadctx.md'],
114
+ kind: 'dedicated',
115
+ frontmatter: ['trigger: always_on', 'description: Use threadctx shared team memory'],
116
+ detect: (root) => dirExists(root, '.windsurf'),
117
+ },
118
+ {
119
+ // Cline doesn't read AGENTS.md, so this file is the only way it ever sees the
120
+ // instruction. Cline accepts either a .clinerules file or a .clinerules/ dir.
121
+ key: 'cline',
122
+ label: '.clinerules/threadctx.md',
123
+ relPath: ['.clinerules', 'threadctx.md'],
124
+ kind: 'dedicated',
125
+ detect: (root) => pathExists(root, '.clinerules'),
126
+ },
127
+ {
128
+ // Gemini CLI uses its own GEMINI.md (doesn't read AGENTS.md).
129
+ key: 'gemini',
130
+ label: 'GEMINI.md',
131
+ relPath: ['GEMINI.md'],
132
+ kind: 'shared',
133
+ detect: (root) => dirExists(root, '.gemini'),
134
+ },
135
+ ];
136
+ function applyTarget(projectRoot, t) {
137
+ const filePath = join(projectRoot, ...t.relPath);
138
+ return t.kind === 'shared'
139
+ ? upsertMarkedBlock(filePath)
140
+ : upsertDedicatedFile(filePath, t.frontmatter ?? []);
141
+ }
142
+ /**
143
+ * Idempotently ensure every applicable agent's project rules contain the "check
144
+ * team memory" instruction. Universal files (AGENTS.md, CLAUDE.md) and Cursor
145
+ * are always written; tool-specific files (Copilot, Windsurf, Cline, Gemini) are
146
+ * written only when that tool's footprint is detected in the repo, so we never
147
+ * scatter rule files for tools the team doesn't use. Safe to call on every
148
+ * server start — repeat calls are no-ops once each file is already current.
149
+ */
150
+ export function applyRules(projectRoot) {
151
+ const applied = [];
152
+ for (const t of TARGETS) {
153
+ if (t.detect && !t.detect(projectRoot))
154
+ continue;
155
+ applied.push({ key: t.key, label: t.label, result: applyTarget(projectRoot, t) });
156
+ }
157
+ return applied;
158
+ }
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
7
7
  import { loadConfig } from './config.js';
8
8
  import { LocalStore } from './local-store.js';
9
9
  import { CloudClient } from './cloud-client.js';
10
+ import { applyRules, RULES_BODY } from './rules.js';
10
11
  // Consistent attribution string across every surface (Claude Code terminal
11
12
  // output, Cursor's agent panel, future surfaces). See spec section 2.4 —
12
13
  // the same short string everywhere is what makes the brand legible.
@@ -44,7 +45,34 @@ export async function startServer() {
44
45
  const localStore = useCloud ? null : new LocalStore(config.dbPath);
45
46
  const cloudClient = useCloud ? new CloudClient(config.apiUrl, config.apiKey, config.actorId) : null;
46
47
  const repo = config.repo;
47
- const server = new Server({ name: 'threadctx', version: packageVersion }, { capabilities: { tools: {} } });
48
+ // Reliably getting memory_query/memory_write actually *used* is the whole
49
+ // product, so this doesn't wait for a human to run `threadctx init` — it
50
+ // happens on every server start, idempotently (a no-op once both files are
51
+ // already current). Never let a file-permission hiccup here take down the
52
+ // server; opt out entirely with THREADCTX_NO_AUTO_RULES=1.
53
+ if (!process.env.THREADCTX_NO_AUTO_RULES) {
54
+ try {
55
+ const touched = applyRules(process.cwd()).filter((r) => r.result !== 'unchanged');
56
+ if (touched.length > 0) {
57
+ console.error('[threadctx] Added team-memory instructions to your project rules ' +
58
+ `(${touched.map((r) => r.label).join(', ')}) ` +
59
+ '— your agent will check shared memory automatically from now on.');
60
+ }
61
+ }
62
+ catch (err) {
63
+ console.error('[threadctx] Could not write project rules (non-fatal):', err);
64
+ }
65
+ }
66
+ const server = new Server({ name: 'threadctx', version: packageVersion }, {
67
+ capabilities: { tools: {} },
68
+ // Sent to every MCP client on the `initialize` handshake, so the
69
+ // "check memory before/after" guidance reaches the model even for
70
+ // clients that don't read CLAUDE.md/.cursor/rules, and even before any
71
+ // file gets written. Belt-and-suspenders with the file-based rules
72
+ // above — client-side handling of this field isn't guaranteed uniform
73
+ // across every MCP client, so we don't rely on it alone.
74
+ instructions: RULES_BODY,
75
+ });
48
76
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
49
77
  tools: [
50
78
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadctx-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.threadctx-dev/threadctx-mcp",
5
5
  "description": "Shared memory MCP server for AI coding agents. Local-only by default; point it at threadctx.dev (or your own deployment) to share memory across your team.",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "build": "tsc -p tsconfig.json",
14
14
  "dev": "tsx watch src/cli.ts",
15
15
  "start": "node dist/cli.js",
16
- "test": "npm run build && node test/smoke.mjs",
16
+ "test": "npm run build && node test/rules.test.mjs && node test/capture.test.mjs && node test/smoke.mjs",
17
17
  "prepublishOnly": "npm run build"
18
18
  },
19
19
  "dependencies": {