gigarag-claude-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.mcp.json +8 -0
  3. package/README.md +34 -0
  4. package/agents/gigarag-indexer.md +72 -0
  5. package/bin/gigarag +4 -0
  6. package/cli/auth/credentials.js +106 -0
  7. package/cli/auth/oauth.js +203 -0
  8. package/cli/auth/page.js +68 -0
  9. package/cli/bin.js +8 -0
  10. package/cli/cli.js +90 -0
  11. package/cli/clients/commands.js +160 -0
  12. package/cli/clients/connect.js +217 -0
  13. package/cli/clients/inspect.js +74 -0
  14. package/cli/clients/json.js +135 -0
  15. package/cli/clients/launcher.js +71 -0
  16. package/cli/clients/registry.js +40 -0
  17. package/cli/clients/toml.js +169 -0
  18. package/cli/clients/tomlarray.js +121 -0
  19. package/cli/clients/yaml.js +146 -0
  20. package/cli/clients.json +1226 -0
  21. package/cli/commands/authHeader.js +22 -0
  22. package/cli/commands/connect.js +285 -0
  23. package/cli/commands/indexSync.js +46 -0
  24. package/cli/commands/login.js +129 -0
  25. package/cli/commands/mcp.js +22 -0
  26. package/cli/commands/record.js +72 -0
  27. package/cli/commands/repo.js +48 -0
  28. package/cli/commands/scan.js +72 -0
  29. package/cli/commands/status.js +115 -0
  30. package/cli/config.js +69 -0
  31. package/cli/connect.js +8 -0
  32. package/cli/constants.js +24 -0
  33. package/cli/hooks.js +151 -0
  34. package/cli/index.js +3 -0
  35. package/cli/mcp/bridge.js +123 -0
  36. package/cli/mcp/client.js +156 -0
  37. package/cli/mcp/session.js +79 -0
  38. package/cli/package.json +5 -0
  39. package/cli/paths.js +34 -0
  40. package/cli/prompts.generated.js +44 -0
  41. package/cli/prompts.js +48 -0
  42. package/cli/scan/chunk.js +43 -0
  43. package/cli/scan/ignore.js +117 -0
  44. package/cli/scan/repo.js +99 -0
  45. package/cli/scan/scan.js +262 -0
  46. package/cli/scan.js +5 -0
  47. package/cli/sdk.js +130 -0
  48. package/cli/secrets.js +192 -0
  49. package/cli/secureUrl.js +18 -0
  50. package/cli/state.js +210 -0
  51. package/cli/ui.js +66 -0
  52. package/commands/gigadocs.md +19 -0
  53. package/commands/gigaindex.md +10 -0
  54. package/commands/gigarecall.md +15 -0
  55. package/commands/gigasave.md +24 -0
  56. package/commands/gigasync.md +9 -0
  57. package/hooks/hooks.json +38 -0
  58. package/package.json +26 -0
  59. package/scripts/run.mjs +64 -0
@@ -0,0 +1,156 @@
1
+ import { DEFAULT_MCP_URL, MAX_BATCH, version } from '../constants.js';
2
+ export class McpHttpError extends Error {
3
+ status;
4
+ code;
5
+ constructor(message, status, code = undefined) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ this.name = 'McpHttpError';
10
+ }
11
+ }
12
+ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
13
+ /**
14
+ * The one HTTP path to core's /mcp. The bridge, the library and the status
15
+ * command all go through it, so the 20-message ceiling and the 429 handling
16
+ * exist once.
17
+ */
18
+ export class McpHttpClient {
19
+ url;
20
+ opts;
21
+ nextId = 1;
22
+ constructor(opts) {
23
+ this.opts = opts;
24
+ this.url = opts.url ?? DEFAULT_MCP_URL;
25
+ }
26
+ /** Sends one message or one batch. A batch over MAX_BATCH is split, never sent whole. */
27
+ async post(message) {
28
+ if (Array.isArray(message) && message.length > MAX_BATCH) {
29
+ const merged = [];
30
+ let status = 200;
31
+ for (let i = 0; i < message.length; i += MAX_BATCH) {
32
+ const part = await this.post(message.slice(i, i + MAX_BATCH));
33
+ status = part.status;
34
+ if (Array.isArray(part.body))
35
+ merged.push(...part.body);
36
+ else if (part.body)
37
+ merged.push(part.body);
38
+ }
39
+ return { status, body: merged };
40
+ }
41
+ return this.send(message, false);
42
+ }
43
+ async send(message, retriedAuth) {
44
+ const fetchImpl = this.opts.fetch ?? fetch;
45
+ const sleep = this.opts.sleep ?? defaultSleep;
46
+ const maxRetries = this.opts.maxRetries ?? 5;
47
+ const maxWait = this.opts.maxWaitSeconds ?? 60;
48
+ for (let attempt = 0;; attempt++) {
49
+ const auth = await this.opts.authorization();
50
+ const res = await fetchImpl(this.url, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'content-type': 'application/json',
54
+ accept: 'application/json, text/event-stream',
55
+ 'user-agent': this.opts.userAgent ?? `gigarag/${version()}`,
56
+ ...(auth ? { authorization: auth } : {}),
57
+ },
58
+ body: JSON.stringify(message),
59
+ // A server that accepts the connection and never answers must not hold a client's request for good.
60
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 120_000),
61
+ });
62
+ if (res.status === 429 && attempt < maxRetries) {
63
+ const asked = Number(res.headers.get('retry-after'));
64
+ // A missing header still backs off, so a limiter that forgets it does not get hammered.
65
+ const wait = Math.min(Number.isFinite(asked) && asked > 0 ? asked : 2 ** attempt, maxWait);
66
+ this.opts.onRetry?.(wait, attempt + 1);
67
+ await sleep(wait * 1000);
68
+ continue;
69
+ }
70
+ if (res.status === 401 && !retriedAuth && this.opts.onUnauthorized && (await this.opts.onUnauthorized())) {
71
+ return this.send(message, true);
72
+ }
73
+ return this.readResponse(res);
74
+ }
75
+ }
76
+ async readResponse(res) {
77
+ const text = await res.text();
78
+ const type = res.headers.get('content-type') ?? '';
79
+ let body;
80
+ if (text.trim()) {
81
+ try {
82
+ body = type.includes('text/event-stream') ? parseEventStream(text) : JSON.parse(text);
83
+ }
84
+ catch {
85
+ body = undefined;
86
+ // A proxy's HTML page on a 200 is not a reply, and passing it on as nothing would hang the caller.
87
+ if (res.status < 400)
88
+ throw new McpHttpError('GigaRAG sent a reply that is not JSON. Something between here and the server may be rewriting it.', res.status);
89
+ }
90
+ }
91
+ if (res.status >= 400) {
92
+ const first = Array.isArray(body) ? body[0] : body;
93
+ throw new McpHttpError(describeFailure(res.status, first?.error?.message), res.status, codeOf(text));
94
+ }
95
+ return { status: res.status, body };
96
+ }
97
+ /** A single request, its id assigned here. Returns `result`, throws on a JSON-RPC error. */
98
+ async rpc(method, params) {
99
+ const id = this.nextId++;
100
+ const { body } = await this.post({ jsonrpc: '2.0', id, method, params });
101
+ const message = Array.isArray(body) ? body.find(m => m.id === id) : body;
102
+ if (!message)
103
+ throw new McpHttpError('The server sent no reply.', 0);
104
+ if (message.error)
105
+ throw new McpHttpError(message.error.message, 200, String(message.error.code));
106
+ return message.result;
107
+ }
108
+ async notify(method, params) {
109
+ await this.post({ jsonrpc: '2.0', method, params });
110
+ }
111
+ }
112
+ function codeOf(text) {
113
+ try {
114
+ const parsed = JSON.parse(text);
115
+ if (typeof parsed.code === 'string')
116
+ return parsed.code;
117
+ if (typeof parsed.error === 'string')
118
+ return parsed.error;
119
+ }
120
+ catch {
121
+ /* not JSON */
122
+ }
123
+ return /invalid_api_key/.exec(text)?.[0];
124
+ }
125
+ export function describeFailure(status, detail) {
126
+ if (status === 401)
127
+ return 'GigaRAG did not accept the key (401). Run: gigarag login';
128
+ if (status === 403)
129
+ return 'This key is not allowed to do that (403).';
130
+ if (status === 429)
131
+ return 'GigaRAG is rate limiting this key (429) and retries ran out.';
132
+ if (status >= 500)
133
+ return `GigaRAG had a problem (HTTP ${status}). Try again shortly.`;
134
+ return detail ? `${detail} (HTTP ${status})` : `GigaRAG answered HTTP ${status}.`;
135
+ }
136
+ /** The `data:` payloads of a server-sent event stream, as messages. */
137
+ export function parseEventStream(text) {
138
+ const messages = [];
139
+ for (const block of text.split(/\r?\n\r?\n/)) {
140
+ const data = block
141
+ .split(/\r?\n/)
142
+ .filter(line => line.startsWith('data:'))
143
+ .map(line => line.slice(5).trimStart())
144
+ .join('\n');
145
+ if (!data)
146
+ continue;
147
+ const parsed = JSON.parse(data);
148
+ if (Array.isArray(parsed))
149
+ messages.push(...parsed);
150
+ else
151
+ messages.push(parsed);
152
+ }
153
+ if (messages.length === 0)
154
+ return undefined;
155
+ return messages.length === 1 ? messages[0] : messages;
156
+ }
@@ -0,0 +1,79 @@
1
+ import { readConfig } from '../config.js';
2
+ import { DEFAULT_MCP_URL } from '../constants.js';
3
+ import { loadCredential, refreshOAuth } from '../auth/credentials.js';
4
+ import { McpHttpClient } from './client.js';
5
+ import { assertSecureUrl } from '../secureUrl.js';
6
+ export { assertSecureUrl };
7
+ export class NotSignedInError extends Error {
8
+ constructor() {
9
+ super('Not signed in to GigaRAG. Run: gigarag login');
10
+ this.name = 'NotSignedInError';
11
+ }
12
+ }
13
+ /** The endpoint: an explicit option, then `GIGARAG_MCP_URL`, then what login stored, then production. */
14
+ export function resolveUrl(explicit) {
15
+ return assertSecureUrl(explicit ?? process.env['GIGARAG_MCP_URL'] ?? readConfig().mcpUrl ?? DEFAULT_MCP_URL);
16
+ }
17
+ const REFRESH_MARGIN_MS = 60_000;
18
+ /**
19
+ * A client wired to whatever credential this machine holds. OAuth tokens refresh themselves, on
20
+ * expiry and on a 401.
21
+ *
22
+ * A refresh token works once. Three things follow. The refreshed pair replaces the one held here,
23
+ * so the next call does not present a spent token. Concurrent calls share one refresh. And before
24
+ * refreshing, the stored credential is read again, because a bridge runs in every client the person
25
+ * uses and another of them may have refreshed already, in which case its pair is the one to use.
26
+ */
27
+ export function createClient(options = {}) {
28
+ let credential = options.apiKey
29
+ ? { type: 'key', key: options.apiKey }
30
+ : (options.credential ?? loadCredential());
31
+ const fetchImpl = options.fetch ?? fetch;
32
+ let refreshing;
33
+ const refresh = (current) => {
34
+ refreshing ??= (async () => {
35
+ const stored = options.credential || options.apiKey ? undefined : loadCredential();
36
+ if (stored?.type === 'oauth' && stored.accessToken !== current.accessToken) {
37
+ const fresh = !stored.expiresAt || stored.expiresAt - Date.now() > REFRESH_MARGIN_MS;
38
+ if (fresh)
39
+ return stored;
40
+ }
41
+ try {
42
+ return await refreshOAuth(current, fetchImpl);
43
+ }
44
+ catch (err) {
45
+ // Another process may have spent this refresh token a moment ago and saved a new pair.
46
+ const again = options.credential || options.apiKey ? undefined : loadCredential();
47
+ if (again?.type === 'oauth' && again.refreshToken !== current.refreshToken)
48
+ return again;
49
+ throw err;
50
+ }
51
+ })().finally(() => {
52
+ refreshing = undefined;
53
+ });
54
+ return refreshing;
55
+ };
56
+ return new McpHttpClient({
57
+ ...options,
58
+ url: resolveUrl(options.url),
59
+ authorization: async () => {
60
+ if (!credential)
61
+ throw new NotSignedInError();
62
+ if (credential.type === 'oauth' && credential.refreshToken && credential.expiresAt && credential.expiresAt - Date.now() < REFRESH_MARGIN_MS) {
63
+ credential = await refresh(credential);
64
+ }
65
+ return `Bearer ${credential.type === 'key' ? credential.key : credential.accessToken}`;
66
+ },
67
+ onUnauthorized: async () => {
68
+ if (credential?.type !== 'oauth' || !credential.refreshToken)
69
+ return false;
70
+ try {
71
+ credential = await refresh(credential);
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ },
78
+ });
79
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "gigarag",
3
+ "version": "0.1.0",
4
+ "type": "module"
5
+ }
package/cli/paths.js ADDED
@@ -0,0 +1,34 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /** The state directory. `GIGARAG_HOME` exists so tests never touch the real one. */
4
+ export function stateDir(env = process.env) {
5
+ return env['GIGARAG_HOME'] || join(homedir(), '.gigarag');
6
+ }
7
+ export function currentPlatform() {
8
+ const p = process.platform;
9
+ return p === 'win32' || p === 'darwin' ? p : 'linux';
10
+ }
11
+ /**
12
+ * Expands the path forms clients.json uses: `~`, `%APPDATA%`, `%USERPROFILE%`,
13
+ * `%LOCALAPPDATA%`, `$XDG_CONFIG_HOME`, `$CLAUDE_CONFIG_DIR`. Pure, so every platform is testable
14
+ * from any platform.
15
+ */
16
+ export function expandPath(input, platform, env, home) {
17
+ const appData = env['APPDATA'] || join(home, 'AppData', 'Roaming');
18
+ const localAppData = env['LOCALAPPDATA'] || join(home, 'AppData', 'Local');
19
+ const xdg = env['XDG_CONFIG_HOME'] || join(home, '.config');
20
+ let out = input
21
+ .replace(/^~(?=$|[\\/])/, () => home)
22
+ .replaceAll('%APPDATA%', () => appData)
23
+ .replaceAll('%LOCALAPPDATA%', () => localAppData)
24
+ .replaceAll('%USERPROFILE%', () => home)
25
+ .replaceAll('$XDG_CONFIG_HOME', () => xdg)
26
+ // Claude Code keeps ~/.claude.json inside this folder when it is set.
27
+ .replaceAll('$CLAUDE_CONFIG_DIR', () => env['CLAUDE_CONFIG_DIR'] || home);
28
+ out = platform === 'win32' ? out.replaceAll('/', '\\') : out.replaceAll('\\', '/');
29
+ return out;
30
+ }
31
+ /** The tilde form of a path, for printing. Keeps output short and free of usernames in logs. */
32
+ export function tildify(path, home = homedir()) {
33
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
34
+ }
@@ -0,0 +1,44 @@
1
+ // Generated by scripts/gen-prompts.mjs from plugins/claude-code. Do not edit by hand.
2
+ // Run `npm run build` after changing a command.
3
+ export const PROMPTS = [
4
+ {
5
+ name: "gigadocs",
6
+ description: "Ingest external documentation into GigaRAG so it can be searched later",
7
+ argumentHint: "<url>",
8
+ arguments: [{ "name": "url", "description": "Documentation URL to ingest.", "required": true }],
9
+ needsShell: false,
10
+ text: "Run the CLI as `gigarag`. If the shell says it is not found, use `npx -y gigarag` in its place, with the same arguments.\n\nIngest the documentation at $ARGUMENTS into GigaRAG.\n\nIf that is empty or is not a URL, ask me for one and stop.\n\nThe bucket is named for the site: the hostname with dots turned into hyphens and any leading `www` dropped, so docs.stripe.com becomes `docs-stripe-com`. Find it with `list_buckets`, and create it if it is missing, with the site name as the title.\n\nThen:\n1. Fetch the page. Follow links that stay on the same host and sit under the same path prefix as the starting URL, up to two levels deep and at most 30 pages. Skip anchors, downloads, login pages and anything that repeats a page you already have. Say so if you hit the 30 page limit.\n2. For each page, first call `search_nodes` for its URL. If a memo already carries that URL, update it in place with `update_node` instead of creating a duplicate, so running this twice does not double the bucket.\n3. Otherwise `create_node`. The title is the page's own title. The summary is one or two sentences on what the page covers. The body opens with the line `Source: <url>, retrieved <today's date>`, then the page's content as clean markdown: keep headings, code blocks and tables, drop navigation, footers, cookie banners and repeated menus.\n4. A memo body holds at most 10,000 characters. For a longer page, split at a heading into several memos titled \"Page title, part 2\" and so on, and link each part to the next in its text as `[Page title, part 2](N:12)`.\n5. Set `node_type` to `doc` and `actor` to `agent:claude-code`.\n\nWhen you finish, tell me the bucket, how many pages you created and updated, how many you skipped and why, and one example query I could try with `/gigarecall`.",
11
+ },
12
+ {
13
+ name: "gigaindex",
14
+ description: "Index a codebase into GigaRAG, one bucket per repository",
15
+ argumentHint: "[path]",
16
+ arguments: [{ "name": "path", "description": "Folder to index. Defaults to the current directory.", "required": false }],
17
+ needsShell: true,
18
+ text: "Run the CLI as `gigarag`. If the shell says it is not found, use `npx -y gigarag` in its place, with the same arguments.\n\nThis needs a shell tool, because it runs the `gigarag` command. If you have no way to run shell commands, say so in one sentence and stop. Do not guess at what the repository contains.\n\nIndex a codebase into GigaRAG. The path to index is: \"$ARGUMENTS\". If that is empty, use the current directory.\n\nDo the work yourself, in this conversation, following the procedure below. When you finish, tell the user in five lines or fewer: the bucket, how many memos you created, updated and deleted, anything you could not index and why, and whether the index is up to date. Do not paste the file list.\n\n## Procedure\n\nThe work is split on purpose. The `gigarag` command does the mechanical half: it walks the tree, respects gitignore, hashes contents, plans chunk boundaries and works out what changed. You do the judgement half: what a module is for, what is worth summarising, and what links to what. Never walk the filesystem yourself with ls or find. The manifest names every file you need.\n\n## 1. Scan\n\nRun `gigarag scan <path>` with the path you were given, or the current directory. It prints a JSON manifest. Read these fields:\n\n- `repo.slug` is the bucket. `repo.root` is the repository root.\n- `files` lists new and changed files, each with `path`, `hash`, `status`, `lines`, `language`, `nodeIds` (memos already made from it) and sometimes `chunks` (line ranges, when the file is too long for one memo).\n- `removed` lists files that are gone, each with `nodeIds` and `deleteNodeIds`.\n- `truncated: true` means the walk hit its limit. Tell the user in your report and carry on with what you have.\n\nIf `files` and `removed` are both empty, report \"Up to date\" and stop.\n\n## 2. Find or create the bucket and threads\n\nCall `list_buckets` with `q` set to the slug. Use the bucket whose slug matches exactly, or `create_bucket` with that slug, the repository name as the title, and a one line description. Slugs are lowercase letters, digits and hyphens, at most 64 characters.\n\nGroup memos into threads by the top level area of the repository: `src`, `docs`, `tests`, `config`. Look them up with `list_threads` for the bucket and create the missing ones. Keep the number of threads under about ten.\n\n## 3. Write the memos\n\nWork in batches of about ten files. For each file:\n\n- Read it with the Read tool. When it has `chunks`, read one range at a time using `offset` and `limit`.\n- Write a memo that explains the file to somebody who has not read it. Cover what it is for, its public surface (exported names and signatures, not their bodies), how it connects to the rest of the system, and any non-obvious constraint. Aim for 500 to 2,500 characters. Do not paste the source. A memo that only restates the code is worse than none.\n- Very small files that only make sense together, such as a folder of three tiny helpers, may share one memo. Give each of those files the same node when you record them.\n- The title says what the module is, for example \"Session token refresh\". The summary is one or two sentences. `node_type` is `code` for source, `doc` for markdown and prose, `config` for configuration. The slug comes from the path, for example `src-auth-session-ts`. Set `actor` to `agent:gigarag-indexer`.\n- A body holds at most 10,000 characters. For a long file with `chunks`, write one memo per chunk and a short parent memo that links to them.\n- If `nodeIds` is not empty, the file was indexed before. Call `update_node` on that node instead of creating a second one, and keep the `[Title](N:12)` links already in its text unless the thing they point at is gone.\n- Where this memo depends on another module, link it in the text as `[Module name](N:12)`. Get the ref from `find_nodes` with part of the title, or from a memo you have already written this run. Never write a UUID as a link target. After each write, look at `unresolved_links` in the reply and fix or remove any ref that did not resolve.\n\n## 4. Record progress after every batch\n\nAfter each batch, pipe the result to `gigarag record` so a later run updates in place and an interrupted run can resume. Send JSON on stdin:\n\n{\"repo\":\"<slug>\",\"files\":[{\"path\":\"src/auth.ts\",\"hash\":\"<hash from the manifest>\",\"nodes\":[{\"id\":\"<node id>\",\"ref\":\"N:12\",\"title\":\"Session token refresh\",\"type\":\"code\",\"tokens\":600}]}]}\n\n`tokens` is your rough estimate of what fetching the memo costs, at about four characters a token. Do this per batch, not once at the end.\n\n## 5. Remove what is gone\n\nFor each entry in `removed`, call `delete_node` for every id in its `deleteNodeIds`. For memos that other files still feed, use `update_node` to take the removed file out of the text. Then record it:\n\n{\"repo\":\"<slug>\",\"removed\":[{\"path\":\"src/old.ts\",\"deletedNodeIds\":[\"<node id>\"]}]}\n\nWithout this half the index only ever grows and drifts away from the repository while still looking correct.\n\n## Rate limits\n\nIf a tool answers that you are rate limited, wait and continue. Do not start over. Files you have already recorded are skipped by the next `gigarag scan`, so an interrupted index resumes where it stopped.\n\n## Report\n\nEnd with this, and nothing longer:\n\n- Bucket: the slug\n- Memos: created N, updated N, deleted N\n- Not indexed: what and why, or \"none\"\n- Status: up to date, or what is left",
19
+ },
20
+ {
21
+ name: "gigarecall",
22
+ description: "Search GigaRAG and load what it knows into this conversation",
23
+ argumentHint: "<what to recall>",
24
+ arguments: [{ "name": "query", "description": "What to recall.", "required": true }],
25
+ needsShell: false,
26
+ text: "Run the CLI as `gigarag`. If the shell says it is not found, use `npx -y gigarag` in its place, with the same arguments.\n\nRecall what GigaRAG knows about: $ARGUMENTS\n\nIf that is empty, ask me what to recall and stop.\n\n1. Call `search_nodes` with my question in plain words, limit 8. It combines exact-term and meaning-based search, so a paraphrase works as well as an identifier.\n2. Read the two to four most relevant hits in full with `fetch_node`. Search returns only a snippet, so do not answer from snippets alone.\n3. If a memo links to another that looks necessary to answer, read that one too with `fetch_node`, or use `neighbors`. Stop after about six memos in total.\n4. Give me what you found: the facts that answer the question, each with the ref of the memo it came from, like N:12. Say which memos disagree, if any do, and which is newer. If a memo is marked superseded, say so and prefer what replaced it.\n\nIf nothing relevant came back, say that plainly and suggest one different phrasing to try. Do not fill the gap from general knowledge, and do not save anything.",
27
+ },
28
+ {
29
+ name: "gigasave",
30
+ description: "Save this session's decisions and discoveries to GigaRAG as memos",
31
+ argumentHint: "[what to focus on]",
32
+ arguments: [{ "name": "focus", "description": "What to save. Defaults to the whole conversation.", "required": false }],
33
+ needsShell: false,
34
+ text: "Run the CLI as `gigarag`. If the shell says it is not found, use `npx -y gigarag` in its place, with the same arguments.\n\nSave what this session decided or learned into GigaRAG, so the next session, on any machine or in any client, can find it. If I gave a focus (\"$ARGUMENTS\"), save only that. Otherwise go through the whole conversation.\n\nFind the bucket first: run `gigarag repo --slug` and use that slug. If no bucket has it yet, create one with that slug.\n\nWhat is worth a memo:\n- A decision, with why it was made and what was rejected. \"We chose X over Y because Z\" is the best kind.\n- A discovery that took effort: why something behaves as it does, a constraint nobody wrote down, a bug's real cause.\n- A convention agreed for this codebase.\n- An open question somebody still has to answer.\n\nWhat is not: chatter, status updates, anything already obvious from the code or the git log, and anything secret. Never save keys, tokens, passwords or personal data, even if they appeared in the conversation.\n\nFor each one:\n1. Call `search_nodes` with the topic first. If a memo already covers it, update that memo with `update_node`, keeping the links already in its text, instead of creating a second one.\n2. Otherwise `create_node` in a thread called `decisions` in that bucket (create the thread if it is missing). Use the type `decision` for decisions and `note` for everything else. Set `actor` to `agent:claude-code`.\n3. The title states the point (\"Use cursor pagination for /events\"), not the topic (\"Pagination\"). The summary is one or two sentences. The body has the context, the decision, why, and what was rejected.\n4. Where a memo relates to another, link it in the text as `[Title](N:12)`, using the ref from `find_nodes`. Never write a UUID as a link target.\n\nAt the end, list what you saved as one line each: the ref, the title, and whether it was created or updated. If there was nothing worth saving, say so and save nothing.",
35
+ },
36
+ {
37
+ name: "gigasync",
38
+ description: "Bring GigaRAG up to date with the files that changed since the last index",
39
+ argumentHint: "",
40
+ arguments: [],
41
+ needsShell: true,
42
+ text: "Run the CLI as `gigarag`. If the shell says it is not found, use `npx -y gigarag` in its place, with the same arguments.\n\nThis needs a shell tool, because it runs the `gigarag` command. If you have no way to run shell commands, say so in one sentence and stop. Do not guess at what the repository contains.\n\nRun `gigarag scan --summary` in the current directory and read the counts.\n\nIf nothing is new, changed or gone, say \"GigaRAG is up to date with this repository.\" and stop.\n\nOtherwise do an incremental sync yourself, in this conversation, following the procedure below and touching only the files the scan reports. When you finish, tell the user in three lines or fewer what changed: memos created, updated and deleted.\n\n## Procedure\n\nThe work is split on purpose. The `gigarag` command does the mechanical half: it walks the tree, respects gitignore, hashes contents, plans chunk boundaries and works out what changed. You do the judgement half: what a module is for, what is worth summarising, and what links to what. Never walk the filesystem yourself with ls or find. The manifest names every file you need.\n\n## 1. Scan\n\nRun `gigarag scan <path>` with the path you were given, or the current directory. It prints a JSON manifest. Read these fields:\n\n- `repo.slug` is the bucket. `repo.root` is the repository root.\n- `files` lists new and changed files, each with `path`, `hash`, `status`, `lines`, `language`, `nodeIds` (memos already made from it) and sometimes `chunks` (line ranges, when the file is too long for one memo).\n- `removed` lists files that are gone, each with `nodeIds` and `deleteNodeIds`.\n- `truncated: true` means the walk hit its limit. Tell the user in your report and carry on with what you have.\n\nIf `files` and `removed` are both empty, report \"Up to date\" and stop.\n\n## 2. Find or create the bucket and threads\n\nCall `list_buckets` with `q` set to the slug. Use the bucket whose slug matches exactly, or `create_bucket` with that slug, the repository name as the title, and a one line description. Slugs are lowercase letters, digits and hyphens, at most 64 characters.\n\nGroup memos into threads by the top level area of the repository: `src`, `docs`, `tests`, `config`. Look them up with `list_threads` for the bucket and create the missing ones. Keep the number of threads under about ten.\n\n## 3. Write the memos\n\nWork in batches of about ten files. For each file:\n\n- Read it with the Read tool. When it has `chunks`, read one range at a time using `offset` and `limit`.\n- Write a memo that explains the file to somebody who has not read it. Cover what it is for, its public surface (exported names and signatures, not their bodies), how it connects to the rest of the system, and any non-obvious constraint. Aim for 500 to 2,500 characters. Do not paste the source. A memo that only restates the code is worse than none.\n- Very small files that only make sense together, such as a folder of three tiny helpers, may share one memo. Give each of those files the same node when you record them.\n- The title says what the module is, for example \"Session token refresh\". The summary is one or two sentences. `node_type` is `code` for source, `doc` for markdown and prose, `config` for configuration. The slug comes from the path, for example `src-auth-session-ts`. Set `actor` to `agent:gigarag-indexer`.\n- A body holds at most 10,000 characters. For a long file with `chunks`, write one memo per chunk and a short parent memo that links to them.\n- If `nodeIds` is not empty, the file was indexed before. Call `update_node` on that node instead of creating a second one, and keep the `[Title](N:12)` links already in its text unless the thing they point at is gone.\n- Where this memo depends on another module, link it in the text as `[Module name](N:12)`. Get the ref from `find_nodes` with part of the title, or from a memo you have already written this run. Never write a UUID as a link target. After each write, look at `unresolved_links` in the reply and fix or remove any ref that did not resolve.\n\n## 4. Record progress after every batch\n\nAfter each batch, pipe the result to `gigarag record` so a later run updates in place and an interrupted run can resume. Send JSON on stdin:\n\n{\"repo\":\"<slug>\",\"files\":[{\"path\":\"src/auth.ts\",\"hash\":\"<hash from the manifest>\",\"nodes\":[{\"id\":\"<node id>\",\"ref\":\"N:12\",\"title\":\"Session token refresh\",\"type\":\"code\",\"tokens\":600}]}]}\n\n`tokens` is your rough estimate of what fetching the memo costs, at about four characters a token. Do this per batch, not once at the end.\n\n## 5. Remove what is gone\n\nFor each entry in `removed`, call `delete_node` for every id in its `deleteNodeIds`. For memos that other files still feed, use `update_node` to take the removed file out of the text. Then record it:\n\n{\"repo\":\"<slug>\",\"removed\":[{\"path\":\"src/old.ts\",\"deletedNodeIds\":[\"<node id>\"]}]}\n\nWithout this half the index only ever grows and drifts away from the repository while still looking correct.\n\n## Rate limits\n\nIf a tool answers that you are rate limited, wait and continue. Do not start over. Files you have already recorded are skipped by the next `gigarag scan`, so an interrupted index resumes where it stopped.\n\n## Report\n\nEnd with this, and nothing longer:\n\n- Bucket: the slug\n- Memos: created N, updated N, deleted N\n- Not indexed: what and why, or \"none\"\n- Status: up to date, or what is left",
43
+ },
44
+ ];
package/cli/prompts.js ADDED
@@ -0,0 +1,48 @@
1
+ import { PROMPTS } from './prompts.generated.js';
2
+ export { PROMPTS };
3
+ export function listPrompts() {
4
+ return PROMPTS.map(p => ({
5
+ name: p.name,
6
+ // The command name is the product, so it is also the display name.
7
+ title: `/${p.name}`,
8
+ description: p.description,
9
+ arguments: p.arguments,
10
+ }));
11
+ }
12
+ export class UnknownPromptError extends Error {
13
+ constructor(name) {
14
+ super(`Unknown prompt: ${name}`);
15
+ this.name = 'UnknownPromptError';
16
+ }
17
+ }
18
+ export class MissingArgumentError extends Error {
19
+ constructor(prompt, argument) {
20
+ super(`Prompt ${prompt} needs the argument "${argument}"`);
21
+ this.name = 'MissingArgumentError';
22
+ }
23
+ }
24
+ /**
25
+ * Renders one prompt. Prompt arguments are always strings in MCP, and this
26
+ * package's commands each take at most one, so the value replaces `$ARGUMENTS`
27
+ * wherever it appears. An unknown argument name is ignored rather than refused,
28
+ * because clients differ in how they name the single free-text slot.
29
+ */
30
+ export function getPrompt(name, args = {}) {
31
+ const def = PROMPTS.find(p => p.name === name);
32
+ if (!def)
33
+ throw new UnknownPromptError(name);
34
+ const first = def.arguments[0];
35
+ let value = '';
36
+ if (first) {
37
+ const given = args[first.name];
38
+ // A client that fills in one free-text box may not use our argument name, so take the only value it sent.
39
+ const fallback = Object.values(args).find(v => typeof v === 'string');
40
+ value = typeof given === 'string' ? given : typeof fallback === 'string' ? fallback : '';
41
+ if (first.required && value.trim() === '')
42
+ throw new MissingArgumentError(name, first.name);
43
+ }
44
+ return {
45
+ description: def.description,
46
+ messages: [{ role: 'user', content: { type: 'text', text: def.text.replaceAll('$ARGUMENTS', () => value.trim()) } }],
47
+ };
48
+ }
@@ -0,0 +1,43 @@
1
+ import { MAX_CONTENT_LENGTH } from '../constants.js';
2
+ const BOUNDARY = /^(#{1,6}\s|(export\s+)?(async\s+)?(function|class|interface|type|const|def|fn|func|pub|impl|struct|enum|module|package)\b)/;
3
+ /**
4
+ * Splits a file into ranges of lines, each short enough for one memo.
5
+ *
6
+ * A memo body is capped at 10,000 characters and headings decide how the server
7
+ * sections it, so the plan breaks where a heading or a top-level declaration
8
+ * starts, or at a blank line, and only mid-block when a block is longer than
9
+ * the limit by itself. The limit here is lower than the server's, so the agent
10
+ * has room for a title line and a summary around the excerpt.
11
+ */
12
+ export function planChunks(text, max = MAX_CONTENT_LENGTH - 1000) {
13
+ if (text.length <= max)
14
+ return [];
15
+ const lines = text.split('\n');
16
+ // A trailing newline ends the last line; it does not start another one.
17
+ if (lines.length > 1 && lines[lines.length - 1] === '')
18
+ lines.pop();
19
+ const chunks = [];
20
+ let start = 0;
21
+ let size = 0;
22
+ let lastBreak = -1;
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i];
25
+ const cost = line.length + 1;
26
+ if (size + cost > max && i > start) {
27
+ // Prefer the last natural boundary in the back half of this chunk.
28
+ const half = start + Math.floor((i - start) / 2);
29
+ const cut = lastBreak > half ? lastBreak : i;
30
+ chunks.push({ start: start + 1, end: cut });
31
+ start = cut;
32
+ size = lines.slice(start, i).reduce((n, l) => n + l.length + 1, 0);
33
+ lastBreak = -1;
34
+ }
35
+ if (i > start && (line.trim() === '' || BOUNDARY.test(line))) {
36
+ lastBreak = BOUNDARY.test(line) ? i : i + 1;
37
+ }
38
+ size += cost;
39
+ }
40
+ if (start < lines.length)
41
+ chunks.push({ start: start + 1, end: lines.length });
42
+ return chunks;
43
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * A gitignore matcher with no dependencies. It covers what real repositories
3
+ * use: negation, anchoring, directory-only patterns, `**`, `?`, `*` and
4
+ * character classes. Patterns are relative to the directory that holds the
5
+ * ignore file, so each .gitignore in a tree gets its own matcher.
6
+ */
7
+ function escapeRegex(ch) {
8
+ return /[.+^${}()|\\]/.test(ch) ? `\\${ch}` : ch;
9
+ }
10
+ /** Translates one gitignore glob (already stripped of `!`, leading and trailing slash) to a regex source. */
11
+ function globToRegex(glob) {
12
+ let out = '';
13
+ for (let i = 0; i < glob.length; i++) {
14
+ const ch = glob[i];
15
+ if (ch === '*') {
16
+ if (glob[i + 1] === '*') {
17
+ const before = i === 0 || glob[i - 1] === '/';
18
+ const after = glob[i + 2] === '/' || i + 2 >= glob.length;
19
+ if (before && after) {
20
+ // `**/` matches zero or more directories, a trailing `/**` matches everything inside.
21
+ if (glob[i + 2] === '/') {
22
+ out += '(?:.*/)?';
23
+ i += 2;
24
+ }
25
+ else {
26
+ out += '.*';
27
+ i += 1;
28
+ }
29
+ continue;
30
+ }
31
+ out += '[^/]*';
32
+ i += 1;
33
+ continue;
34
+ }
35
+ out += '[^/]*';
36
+ }
37
+ else if (ch === '?') {
38
+ out += '[^/]';
39
+ }
40
+ else if (ch === '[') {
41
+ const end = glob.indexOf(']', i + 2);
42
+ if (end < 0) {
43
+ out += '\\[';
44
+ }
45
+ else {
46
+ let cls = glob.slice(i + 1, end);
47
+ if (cls.startsWith('!'))
48
+ cls = `^${cls.slice(1)}`;
49
+ out += `[${cls.replace(/\\/g, '\\\\')}]`;
50
+ i = end;
51
+ }
52
+ }
53
+ else if (ch === '\\' && i + 1 < glob.length) {
54
+ out += escapeRegex(glob[++i]);
55
+ }
56
+ else {
57
+ out += escapeRegex(ch);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ function parseLine(raw) {
63
+ let line = raw.replace(/\r$/, '');
64
+ if (line === '' || line.startsWith('#'))
65
+ return undefined;
66
+ // Trailing spaces are dropped unless escaped with a backslash.
67
+ line = line.replace(/(?<!\\)\s+$/, '');
68
+ if (line === '')
69
+ return undefined;
70
+ let negate = false;
71
+ if (line.startsWith('!')) {
72
+ negate = true;
73
+ line = line.slice(1);
74
+ }
75
+ if (line.startsWith('\\#') || line.startsWith('\\!'))
76
+ line = line.slice(1);
77
+ let dirOnly = false;
78
+ if (line.endsWith('/')) {
79
+ dirOnly = true;
80
+ line = line.slice(0, -1);
81
+ }
82
+ if (line === '')
83
+ return undefined;
84
+ // A slash anywhere but the end anchors the pattern to this directory. Without one it matches at any depth.
85
+ const anchored = line.includes('/');
86
+ if (line.startsWith('/'))
87
+ line = line.slice(1);
88
+ const body = globToRegex(line);
89
+ const regex = new RegExp(`^${anchored ? '' : '(?:.*/)?'}${body}$`);
90
+ return { regex, negate, dirOnly };
91
+ }
92
+ export class Ignore {
93
+ rules;
94
+ constructor(source) {
95
+ this.rules = source
96
+ .split('\n')
97
+ .map(parseLine)
98
+ .filter((r) => r !== undefined);
99
+ }
100
+ get empty() {
101
+ return this.rules.length === 0;
102
+ }
103
+ /**
104
+ * `undefined` when no rule speaks to the path, so a parent's verdict can stand.
105
+ * The last matching rule wins, which is what makes `!keep.log` work.
106
+ */
107
+ test(relPath, isDir) {
108
+ let verdict;
109
+ for (const rule of this.rules) {
110
+ if (rule.dirOnly && !isDir)
111
+ continue;
112
+ if (rule.regex.test(relPath))
113
+ verdict = !rule.negate;
114
+ }
115
+ return verdict;
116
+ }
117
+ }
@@ -0,0 +1,99 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { basename, dirname, join, resolve } from 'node:path';
4
+ /** Walks up from `start` to the directory holding `.git`, or returns `start` when there is none. */
5
+ export function findRepoRoot(start) {
6
+ let dir = resolve(start);
7
+ for (;;) {
8
+ if (existsSync(join(dir, '.git')))
9
+ return { root: dir, git: true };
10
+ const parent = dirname(dir);
11
+ if (parent === dir)
12
+ return { root: resolve(start), git: false };
13
+ dir = parent;
14
+ }
15
+ }
16
+ /** The path a checkout's `.git` points at, for worktrees and submodules where `.git` is a file. */
17
+ function gitDir(root) {
18
+ const dot = join(root, '.git');
19
+ try {
20
+ if (statSync(dot).isDirectory())
21
+ return dot;
22
+ const text = readFileSync(dot, 'utf8');
23
+ const m = /^gitdir:\s*(.+)$/m.exec(text);
24
+ if (!m)
25
+ return undefined;
26
+ const target = resolve(root, m[1].trim());
27
+ // A worktree keeps the config in the main repository, two levels up from its own gitdir.
28
+ const common = join(target, 'commondir');
29
+ if (existsSync(common))
30
+ return resolve(target, readFileSync(common, 'utf8').trim());
31
+ return target;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /** `remote "origin"` url, read from the config file so no git binary runs. */
38
+ export function readOriginUrl(root) {
39
+ const dir = gitDir(root);
40
+ if (!dir)
41
+ return undefined;
42
+ try {
43
+ const text = readFileSync(join(dir, 'config'), 'utf8');
44
+ let inOrigin = false;
45
+ for (const line of text.split(/\r?\n/)) {
46
+ const header = /^\s*\[(.+)\]\s*$/.exec(line);
47
+ if (header) {
48
+ inOrigin = /^remote\s+"origin"$/.test(header[1].trim());
49
+ continue;
50
+ }
51
+ const url = inOrigin ? /^\s*url\s*=\s*(.+?)\s*$/.exec(line) : null;
52
+ if (url)
53
+ return url[1];
54
+ }
55
+ }
56
+ catch {
57
+ /* no config */
58
+ }
59
+ return undefined;
60
+ }
61
+ /** `git@github.com:acme/api.git` and `https://github.com/acme/api` both become `acme-api`. */
62
+ export function slugFromRemote(remote) {
63
+ const cleaned = remote.trim().replace(/\/+$/, '').replace(/\.git$/, '');
64
+ const m = /[:/]([^:/]+)\/([^:/]+)$/.exec(cleaned) ?? /[:/]([^:/]+)$/.exec(cleaned);
65
+ if (!m)
66
+ return undefined;
67
+ const parts = m.slice(1).filter(Boolean);
68
+ return slugify(parts.join('-'));
69
+ }
70
+ /** Lowercase letters, digits and hyphens, at most 64 characters: what a bucket slug accepts. */
71
+ export function slugify(input) {
72
+ const slug = input
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9]+/g, '-')
75
+ .replace(/^-+|-+$/g, '')
76
+ .slice(0, 64)
77
+ .replace(/-+$/, '');
78
+ return slug || 'repo';
79
+ }
80
+ /** The slug for a folder with no remote: its name, then six hex characters of where it is. */
81
+ export function localSlug(root) {
82
+ const key = process.platform === 'win32' ? root.toLowerCase() : root;
83
+ const hash = createHash('sha256').update(key).digest('hex').slice(0, 6);
84
+ return `${slugify(basename(root)).slice(0, 57).replace(/-+$/, '')}-${hash}`;
85
+ }
86
+ /**
87
+ * One bucket per repository. The slug comes from the remote where there is one,
88
+ * so two checkouts of the same repo on two machines share a bucket.
89
+ *
90
+ * Without a remote it is the directory name plus a short hash of the path. Two projects that are both
91
+ * called `app` would otherwise share one file table, and a scan of the second would report the
92
+ * first one's files as deleted and offer to delete their memos.
93
+ */
94
+ export function repoInfo(start) {
95
+ const { root, git } = findRepoRoot(start);
96
+ const remote = git ? readOriginUrl(root) : undefined;
97
+ const slug = (remote && slugFromRemote(remote)) || localSlug(root);
98
+ return { root, slug, ...(remote ? { remote } : {}) };
99
+ }