threadctx-mcp 0.3.0 → 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 +21 -0
- package/README.md +70 -11
- package/dist/capture.d.ts +23 -0
- package/dist/capture.js +291 -0
- package/dist/cli.js +21 -4
- package/dist/cloud-client.d.ts +18 -0
- package/dist/cloud-client.js +23 -2
- package/dist/llm.d.ts +20 -0
- package/dist/llm.js +109 -0
- package/dist/rules.d.ts +11 -8
- package/dist/rules.js +97 -39
- package/dist/server.js +2 -3
- package/package.json +2 -2
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
|
|
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
|
|
|
@@ -22,7 +25,8 @@ per-client integration work.
|
|
|
22
25
|
|
|
23
26
|
```bash
|
|
24
27
|
# Local mode — nothing to configure. Also auto-adds the "check team memory"
|
|
25
|
-
# instruction to
|
|
28
|
+
# instruction to your agents' rule files (AGENTS.md, CLAUDE.md, and any
|
|
29
|
+
# detected tool-specific files) on first start.
|
|
26
30
|
npx threadctx-mcp
|
|
27
31
|
|
|
28
32
|
# Optional: write a committable .threadctx.json config, or re-apply the
|
|
@@ -44,12 +48,31 @@ runtime (set it in your MCP client's `env` block, as shown below), so
|
|
|
44
48
|
secrets stay out of version control by construction.
|
|
45
49
|
|
|
46
50
|
threadctx also adds a small, clearly-marked instruction to your project
|
|
47
|
-
rules
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
53
76
|
`THREADCTX_NO_AUTO_RULES=1`, or per-`init`-call with `--no-rules`.
|
|
54
77
|
|
|
55
78
|
The same instruction is also sent as part of the MCP `initialize` handshake
|
|
@@ -102,6 +125,33 @@ Cursor Settings → Tools & MCP):
|
|
|
102
125
|
That's it — the same package and config work in both clients because
|
|
103
126
|
MCP is a portable, open protocol.
|
|
104
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
|
+
|
|
105
155
|
## Environment variables
|
|
106
156
|
|
|
107
157
|
| Variable | Required | Description |
|
|
@@ -111,7 +161,11 @@ MCP is a portable, open protocol.
|
|
|
111
161
|
| `THREADCTX_API_URL` | no | defaults to `https://threadctx.dev/api/v1`; override for self-hosting |
|
|
112
162
|
| `THREADCTX_REPO` | no | overrides repo auto-detection from `git remote` |
|
|
113
163
|
| `THREADCTX_DB_PATH` | no | local-mode store path; defaults to `~/.threadctx/local.json` |
|
|
114
|
-
| `THREADCTX_NO_AUTO_RULES` | no | set to `1` to disable auto-injecting
|
|
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 |
|
|
115
169
|
|
|
116
170
|
## Local development
|
|
117
171
|
|
|
@@ -145,5 +199,10 @@ these three layers together are the strongest guarantee we can build.
|
|
|
145
199
|
| Command | What it does |
|
|
146
200
|
|---|---|
|
|
147
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. |
|
|
148
|
-
| `npx threadctx-mcp init [--mode=cloud --api-key=…] [--no-rules]` | Writes `.threadctx.json` and explicitly (re-)applies the project-rules
|
|
202
|
+
| `npx threadctx-mcp init [--mode=cloud --api-key=…] [--no-rules]` | Writes `.threadctx.json` and explicitly (re-)applies the project-rules files. |
|
|
149
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 {};
|
package/dist/capture.js
ADDED
|
@@ -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
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { loadConfig } from './config.js';
|
|
5
5
|
import { LocalStore } from './local-store.js';
|
|
6
6
|
import { applyRules } from './rules.js';
|
|
7
|
+
import { runCapture } from './capture.js';
|
|
7
8
|
import { startServer } from './server.js';
|
|
8
9
|
const [, , command, ...rest] = process.argv;
|
|
9
10
|
function parseFlags(args) {
|
|
@@ -46,10 +47,12 @@ function runInit(args) {
|
|
|
46
47
|
// server also does this automatically on every start (see server.ts) — this
|
|
47
48
|
// just lets you trigger it explicitly and see the result immediately.
|
|
48
49
|
if (flags['no-rules'] !== true) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
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.`);
|
|
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.)');
|
|
53
56
|
}
|
|
54
57
|
console.log('');
|
|
55
58
|
if (mode === 'cloud') {
|
|
@@ -150,6 +153,20 @@ async function main() {
|
|
|
150
153
|
runList(rest);
|
|
151
154
|
return;
|
|
152
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
|
+
}
|
|
153
170
|
// No subcommand: this is what Claude Code / Cursor actually launch as the
|
|
154
171
|
// MCP server process (they invoke `npx threadctx-mcp` with no arguments).
|
|
155
172
|
await startServer();
|
package/dist/cloud-client.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/cloud-client.js
CHANGED
|
@@ -12,7 +12,7 @@ export class CloudClient {
|
|
|
12
12
|
this.apiKey = apiKey;
|
|
13
13
|
this.actorId = actorId;
|
|
14
14
|
}
|
|
15
|
-
|
|
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
|
+
}
|
package/dist/rules.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
export declare const RULES_BODY: string;
|
|
2
2
|
export type RuleResult = 'created' | 'updated' | 'unchanged';
|
|
3
|
-
export interface
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export interface AppliedRule {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
result: RuleResult;
|
|
6
7
|
}
|
|
7
8
|
/**
|
|
8
|
-
* Idempotently ensure
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
15
|
*/
|
|
13
|
-
export declare function applyRules(projectRoot: string):
|
|
16
|
+
export declare function applyRules(projectRoot: string): AppliedRule[];
|
package/dist/rules.js
CHANGED
|
@@ -18,15 +18,21 @@ export const RULES_BODY = [
|
|
|
18
18
|
].join('\n');
|
|
19
19
|
const MARKER_START = '<!-- threadctx:start (managed — edit above/below, not between) -->';
|
|
20
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`;
|
|
21
26
|
/**
|
|
22
27
|
* Insert (or refresh) the threadctx rules block in a shared, general-purpose
|
|
23
|
-
* rules file (CLAUDE.md
|
|
24
|
-
* any of the user's own content outside
|
|
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.
|
|
25
31
|
*/
|
|
26
32
|
function upsertMarkedBlock(filePath) {
|
|
27
|
-
const block = `${MARKER_START}\n${RULES_BODY}\n${MARKER_END}\n`;
|
|
28
33
|
if (!existsSync(filePath)) {
|
|
29
|
-
|
|
34
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
35
|
+
writeFileSync(filePath, managedBlock);
|
|
30
36
|
return 'created';
|
|
31
37
|
}
|
|
32
38
|
const existing = readFileSync(filePath, 'utf-8');
|
|
@@ -36,7 +42,7 @@ function upsertMarkedBlock(filePath) {
|
|
|
36
42
|
if (endIdx !== -1) {
|
|
37
43
|
const before = existing.slice(0, startIdx);
|
|
38
44
|
const after = existing.slice(endIdx + MARKER_END.length);
|
|
39
|
-
const next = `${before}${
|
|
45
|
+
const next = `${before}${managedBlock.trimEnd()}${after}`;
|
|
40
46
|
if (next === existing)
|
|
41
47
|
return 'unchanged';
|
|
42
48
|
writeFileSync(filePath, next);
|
|
@@ -45,37 +51,19 @@ function upsertMarkedBlock(filePath) {
|
|
|
45
51
|
}
|
|
46
52
|
// No managed block yet — append one, keeping the user's existing content.
|
|
47
53
|
const sep = existing.endsWith('\n') ? '\n' : '\n\n';
|
|
48
|
-
writeFileSync(filePath, `${existing}${sep}${
|
|
54
|
+
writeFileSync(filePath, `${existing}${sep}${managedBlock}`);
|
|
49
55
|
return 'updated';
|
|
50
56
|
}
|
|
51
|
-
// Cursor's current, non-deprecated rules format: a dedicated .mdc file per
|
|
52
|
-
// rule under .cursor/rules/, with YAML frontmatter. alwaysApply: true means
|
|
53
|
-
// it's loaded in every chat regardless of which files are open — the right
|
|
54
|
-
// behavior for a project-wide "check team memory" instruction (see
|
|
55
|
-
// https://cursor.com/docs/rules). The legacy single .cursorrules file still
|
|
56
|
-
// works but Cursor's own docs say it will eventually be removed, so new
|
|
57
|
-
// writes target the current format instead.
|
|
58
|
-
function cursorRuleContent() {
|
|
59
|
-
return [
|
|
60
|
-
'---',
|
|
61
|
-
'description: Use threadctx shared team memory',
|
|
62
|
-
'alwaysApply: true',
|
|
63
|
-
'---',
|
|
64
|
-
'',
|
|
65
|
-
MARKER_START,
|
|
66
|
-
RULES_BODY,
|
|
67
|
-
MARKER_END,
|
|
68
|
-
'',
|
|
69
|
-
].join('\n');
|
|
70
|
-
}
|
|
71
57
|
/**
|
|
72
|
-
* Insert (or refresh)
|
|
73
|
-
* filename is ours alone
|
|
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
|
|
74
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.
|
|
75
63
|
*/
|
|
76
|
-
function
|
|
77
|
-
const
|
|
78
|
-
const content =
|
|
64
|
+
function upsertDedicatedFile(filePath, frontmatter) {
|
|
65
|
+
const header = frontmatter.length ? `---\n${frontmatter.join('\n')}\n---\n\n` : '';
|
|
66
|
+
const content = `${header}${managedBlock}`;
|
|
79
67
|
if (!existsSync(filePath)) {
|
|
80
68
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
81
69
|
writeFileSync(filePath, content);
|
|
@@ -86,15 +74,85 @@ function upsertCursorRule(projectRoot) {
|
|
|
86
74
|
writeFileSync(filePath, content);
|
|
87
75
|
return 'updated';
|
|
88
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
|
+
}
|
|
89
142
|
/**
|
|
90
|
-
* Idempotently ensure
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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.
|
|
94
149
|
*/
|
|
95
150
|
export function applyRules(projectRoot) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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;
|
|
100
158
|
}
|
package/dist/server.js
CHANGED
|
@@ -52,11 +52,10 @@ export async function startServer() {
|
|
|
52
52
|
// server; opt out entirely with THREADCTX_NO_AUTO_RULES=1.
|
|
53
53
|
if (!process.env.THREADCTX_NO_AUTO_RULES) {
|
|
54
54
|
try {
|
|
55
|
-
const
|
|
56
|
-
const touched = Object.entries(results).filter(([, r]) => r !== 'unchanged');
|
|
55
|
+
const touched = applyRules(process.cwd()).filter((r) => r.result !== 'unchanged');
|
|
57
56
|
if (touched.length > 0) {
|
|
58
57
|
console.error('[threadctx] Added team-memory instructions to your project rules ' +
|
|
59
|
-
`(${touched.map((
|
|
58
|
+
`(${touched.map((r) => r.label).join(', ')}) ` +
|
|
60
59
|
'— your agent will check shared memory automatically from now on.');
|
|
61
60
|
}
|
|
62
61
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "threadctx-mcp",
|
|
3
|
-
"version": "0.
|
|
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": {
|