gigarag-cursor 0.1.2 → 0.2.1

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.
@@ -0,0 +1,159 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { isSea } from 'node:sea';
5
+ import { stateDir } from '../paths.js';
6
+ import { State } from '../state.js';
7
+ import { isClean } from '../git/git.js';
8
+ import { maxRewriteFiles, writeNote } from './notes.js';
9
+ import { syncSlug } from './slug.js';
10
+ import { RepoSyncRemote } from './remote.js';
11
+ import { claudeInvocation, findClaude, runRewriter } from './rewriter.js';
12
+ import { finishSync, holderFor, leaseMetaKey, runStage, WORKER_LOCK_MS as LOCK_MS } from './stage.js';
13
+ import { syncTarget } from './target.js';
14
+ const TICK_MS = 4 * 60_000;
15
+ const CHILD_TIMEOUT_MS = 40 * 60_000;
16
+ const quietMs = () => {
17
+ const n = Number(process.env['GIGARAG_SYNC_QUIET_MS']);
18
+ return Number.isFinite(n) && n >= 0 ? n : 20_000;
19
+ };
20
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
21
+ /** Starts the background worker for `root`, detached from whatever is calling this, and does not
22
+ * wait for it. Used by a git hook, and by `sync end`/`sync begin` when clearing the interactive
23
+ * marker frees a hook-mode sync that deferred behind it (its `deferred` note), so that commit
24
+ * gets synced once the marker is gone instead of waiting for another one to arrive. */
25
+ export function spawnWorker(root) {
26
+ const [cmd, args] = isSea()
27
+ ? [process.execPath, ['sync', 'worker', root]]
28
+ : [process.execPath, [fileURLToPath(new URL('../bin.js', import.meta.url)), 'sync', 'worker', root]];
29
+ spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true, cwd: root }).unref();
30
+ }
31
+ /**
32
+ * What a git hook runs. Records that a sync was asked for and starts the
33
+ * worker detached, then returns, so the hook costs one Node start in the
34
+ * background. Every failure ends as exit 0 and silence.
35
+ */
36
+ export async function hookEntry(_hook) {
37
+ try {
38
+ if (process.env['GIGARAG_NO_SYNC'] || process.env['GIGARAG_SYNC_WORKER'])
39
+ return 0;
40
+ const { root, git, slug } = syncSlug(process.cwd());
41
+ if (!git)
42
+ return 0;
43
+ const state = new State();
44
+ try {
45
+ state.setMeta(`sync_request:${slug}`, String(Date.now()));
46
+ }
47
+ finally {
48
+ state.close();
49
+ }
50
+ spawnWorker(root);
51
+ }
52
+ catch {
53
+ /* a hook never fails the git command that ran it */
54
+ }
55
+ return 0;
56
+ }
57
+ /**
58
+ * The background run. One per repository at a time (a local lock), started no
59
+ * sooner than the quiet period after the latest request, and run again if a
60
+ * request arrived while it worked, so a burst of commits costs one run.
61
+ */
62
+ export async function runWorker(root) {
63
+ const target = syncTarget(resolve(root));
64
+ const state = new State();
65
+ const lockKey = `sync_run:${target.slug}`;
66
+ try {
67
+ if (!state.tryAcquire(lockKey, LOCK_MS))
68
+ return 0;
69
+ for (;;) {
70
+ const requested = Number(state.getMeta(`sync_request:${target.slug}`) ?? 0);
71
+ const wait = requested + quietMs() - Date.now();
72
+ if (wait > 0) {
73
+ await sleep(wait);
74
+ continue;
75
+ }
76
+ const started = Date.now();
77
+ state.setMeta(lockKey, String(started));
78
+ try {
79
+ await syncOnce(state, target);
80
+ }
81
+ finally {
82
+ // Release before looking for another request. A hook that fires between the sync
83
+ // finishing and this check must find the lock free, or its request is lost: this
84
+ // run is about to decide there is nothing left to do, and nothing else would ever
85
+ // look again.
86
+ state.setMeta(lockKey, '0');
87
+ }
88
+ if (Number(state.getMeta(`sync_request:${target.slug}`) ?? 0) <= started)
89
+ return 0;
90
+ if (!state.tryAcquire(lockKey, LOCK_MS))
91
+ return 0;
92
+ }
93
+ }
94
+ catch (e) {
95
+ state.setMeta(lockKey, '0');
96
+ writeNote(state, target.slug, { kind: 'failed', detail: e.message });
97
+ return 1;
98
+ }
99
+ finally {
100
+ state.close();
101
+ }
102
+ }
103
+ async function syncOnce(state, target) {
104
+ const stage = await runStage(state, target, { mode: 'hook' });
105
+ if (stage.kind === 'stop') {
106
+ if (stage.note)
107
+ writeNote(state, target.slug, stage.note);
108
+ return;
109
+ }
110
+ const remote = new RepoSyncRemote(target.rag(), stage.bucketId);
111
+ let token = stage.token;
112
+ try {
113
+ const { plan } = stage;
114
+ const work = plan.rewrite.length + plan.partial.length;
115
+ const commit = stage.head;
116
+ if (plan.suspicious) {
117
+ writeNote(state, target.slug, { kind: 'mass_delete', files: plan.goneCount });
118
+ return;
119
+ }
120
+ if (work === 0) {
121
+ await finishSync(state, target, { bucketId: stage.bucketId, token, mode: 'hook' });
122
+ return;
123
+ }
124
+ if (work > maxRewriteFiles()) {
125
+ writeNote(state, target.slug, { kind: 'needs_approval', files: work, ...(commit ? { commit } : {}) });
126
+ return;
127
+ }
128
+ if (!isClean(target.root)) {
129
+ writeNote(state, target.slug, { kind: 'dirty', files: plan.stale.length, ...(commit ? { commit } : {}) });
130
+ return;
131
+ }
132
+ const claude = process.env['GIGARAG_NO_REWRITE'] ? undefined : findClaude(process.env, process.platform);
133
+ if (!claude) {
134
+ writeNote(state, target.slug, { kind: 'no_agent', files: plan.stale.length, ...(commit ? { commit } : {}) });
135
+ return;
136
+ }
137
+ const outcome = await runRewriter(claudeInvocation(claude, process.platform), target.root, join(stateDir(), 'logs', `sync-${target.slug}.log`), {
138
+ timeoutMs: CHILD_TIMEOUT_MS,
139
+ tickMs: TICK_MS,
140
+ onTick: async () => {
141
+ const renewed = await remote.claim(holderFor(state, 'hook'), token);
142
+ if (renewed.granted)
143
+ token = renewed.token;
144
+ },
145
+ env: { ...process.env, GIGARAG_SYNC_WORKER: '1' },
146
+ });
147
+ if (outcome.timedOut || outcome.code !== 0) {
148
+ writeNote(state, target.slug, { kind: 'failed', detail: outcome.timedOut ? 'it ran past 40 minutes and was stopped' : `claude exited with code ${outcome.code ?? 'none'}` });
149
+ return;
150
+ }
151
+ const fin = await finishSync(state, target, { bucketId: stage.bucketId, token, mode: 'hook' });
152
+ if (!fin.advanced)
153
+ writeNote(state, target.slug, { kind: 'failed', detail: `the agent finished with ${fin.remaining} files still to rewrite` });
154
+ }
155
+ finally {
156
+ await remote.release(token);
157
+ state.setMeta(leaseMetaKey(target.slug, 'hook'), '');
158
+ }
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gigarag-cursor",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "The GigaRAG plugin for Cursor. Skills and an MCP server that carry the gigarag CLI inside them.",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
@@ -17,7 +17,7 @@ The work is split on purpose. The `gigarag` command does the mechanical half: it
17
17
 
18
18
  ## 1. Scan
19
19
 
20
- Run `gigarag scan <path>` with the path you were given, or the current directory. It prints a JSON manifest. Read these fields:
20
+ Run `gigarag scan <path>` with the path you were given, or the current directory. If it exits nonzero and prints a line about an untrusted workspace instead of JSON, stop immediately: do not run `gigarag use` yourself, and do not fall back to indexing without a manifest. Report the line it printed and end there. Otherwise it prints a JSON manifest. Read these fields:
21
21
 
22
22
  - `repo.slug` is the bucket. `repo.root` is the repository root.
23
23
  - `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).
@@ -28,7 +28,7 @@ If `files` and `removed` are both empty, report "Up to date" and stop.
28
28
 
29
29
  ## 2. Find or create the bucket and threads
30
30
 
31
- Call `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.
31
+ Call `list_buckets` with `q` set to the slug and use the bucket whose slug matches exactly. If `repo.bound` is true in the manifest, the bucket was chosen in `.gigarag.json`: never call `create_bucket`. If it is not in the list, stop and tell the person that the bucket named in .gigarag.json is not reachable with their access, and that `gigarag use <workspace> --bucket <slug>` links another. Otherwise, when no bucket matches, `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.
32
32
 
33
33
  Group 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.
34
34
 
@@ -42,13 +42,16 @@ Work in batches of about ten files. For each file:
42
42
  - 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`.
43
43
  - 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.
44
44
  - 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.
45
+ - A memo whose source changed carries `stale: true`. Rewriting its content with `update_node` clears the mark. If you read the file and the memo is still right, send its body back unchanged, which clears the mark too.
45
46
  - 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.
46
47
 
47
48
  ## 4. Record progress after every batch
48
49
 
49
- After 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:
50
+ After 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 with a heredoc, so the command line stays `gigarag record` and nothing else:
50
51
 
52
+ gigarag record <<'JSON'
51
53
  {"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}]}]}
54
+ JSON
52
55
 
53
56
  `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.
54
57
 
@@ -19,7 +19,7 @@ The work is split on purpose. The `gigarag` command does the mechanical half: it
19
19
 
20
20
  ## 1. Scan
21
21
 
22
- Run `gigarag scan <path>` with the path you were given, or the current directory. It prints a JSON manifest. Read these fields:
22
+ Run `gigarag scan <path>` with the path you were given, or the current directory. If it exits nonzero and prints a line about an untrusted workspace instead of JSON, stop immediately: do not run `gigarag use` yourself, and do not fall back to indexing without a manifest. Report the line it printed and end there. Otherwise it prints a JSON manifest. Read these fields:
23
23
 
24
24
  - `repo.slug` is the bucket. `repo.root` is the repository root.
25
25
  - `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).
@@ -30,7 +30,7 @@ If `files` and `removed` are both empty, report "Up to date" and stop.
30
30
 
31
31
  ## 2. Find or create the bucket and threads
32
32
 
33
- Call `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.
33
+ Call `list_buckets` with `q` set to the slug and use the bucket whose slug matches exactly. If `repo.bound` is true in the manifest, the bucket was chosen in `.gigarag.json`: never call `create_bucket`. If it is not in the list, stop and tell the person that the bucket named in .gigarag.json is not reachable with their access, and that `gigarag use <workspace> --bucket <slug>` links another. Otherwise, when no bucket matches, `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.
34
34
 
35
35
  Group 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.
36
36
 
@@ -44,13 +44,16 @@ Work in batches of about ten files. For each file:
44
44
  - 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`.
45
45
  - 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.
46
46
  - 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.
47
+ - A memo whose source changed carries `stale: true`. Rewriting its content with `update_node` clears the mark. If you read the file and the memo is still right, send its body back unchanged, which clears the mark too.
47
48
  - 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.
48
49
 
49
50
  ## 4. Record progress after every batch
50
51
 
51
- After 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:
52
+ After 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 with a heredoc, so the command line stays `gigarag record` and nothing else:
52
53
 
54
+ gigarag record <<'JSON'
53
55
  {"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}]}]}
56
+ JSON
54
57
 
55
58
  `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.
56
59