gigarag-cursor 0.1.2 → 0.3.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/.cursor-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/cli/auth/account.js +106 -0
- package/cli/auth/credentials.js +125 -16
- package/cli/binding.js +117 -0
- package/cli/cli.js +10 -0
- package/cli/commands/authHeader.js +5 -8
- package/cli/commands/connect.js +7 -4
- package/cli/commands/gitHooks.js +367 -0
- package/cli/commands/indexSync.js +24 -5
- package/cli/commands/login.js +47 -12
- package/cli/commands/mcp.js +10 -1
- package/cli/commands/repo.js +1 -0
- package/cli/commands/scan.js +21 -1
- package/cli/commands/status.js +33 -15
- package/cli/commands/sync.js +223 -0
- package/cli/commands/trust.js +31 -0
- package/cli/commands/use.js +81 -0
- package/cli/commands/workspaces.js +40 -0
- package/cli/git/git.js +187 -0
- package/cli/git/hookScript.js +83 -0
- package/cli/hooks.js +43 -15
- package/cli/mcp/client.js +4 -0
- package/cli/mcp/session.js +66 -7
- package/cli/package.json +1 -1
- package/cli/prompts.generated.js +18 -2
- package/cli/scan/repo.js +16 -2
- package/cli/scan/scan.js +1 -1
- package/cli/sdk.js +8 -0
- package/cli/state.js +71 -11
- package/cli/sync/notes.js +50 -0
- package/cli/sync/plan.js +76 -0
- package/cli/sync/remote.js +72 -0
- package/cli/sync/rewriter.js +125 -0
- package/cli/sync/slug.js +30 -0
- package/cli/sync/stage.js +323 -0
- package/cli/sync/target.js +14 -0
- package/cli/sync/worker.js +159 -0
- package/package.json +1 -1
- package/skills/gigaimport/SKILL.md +19 -0
- package/skills/gigainbox/SKILL.md +80 -0
- package/skills/gigaindex/SKILL.md +6 -3
- package/skills/gigasync/SKILL.md +6 -3
package/cli/state.js
CHANGED
|
@@ -53,6 +53,16 @@ export class State {
|
|
|
53
53
|
// Several Claude Code sessions write to this file at once, each from its own hook process.
|
|
54
54
|
this.db.exec('PRAGMA busy_timeout = 2000; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;');
|
|
55
55
|
this.db.exec(SCHEMA);
|
|
56
|
+
// Memos from different workspaces share this file. Rows from before the
|
|
57
|
+
// column existed read as workspace '', the legacy single sign-in.
|
|
58
|
+
const columns = this.db.prepare('PRAGMA table_info(nodes)').all();
|
|
59
|
+
if (!columns.some(c => c.name === 'workspace')) {
|
|
60
|
+
this.db.exec("ALTER TABLE nodes ADD COLUMN workspace TEXT NOT NULL DEFAULT ''");
|
|
61
|
+
}
|
|
62
|
+
// Columns added after 0.1.2. SQLite has no ADD COLUMN IF NOT EXISTS, so ask first. An old row
|
|
63
|
+
// counts as unpushed, which is what makes a repository indexed before sync upload its state once.
|
|
64
|
+
this.addColumn('files', 'pushed', 'INTEGER NOT NULL DEFAULT 0');
|
|
65
|
+
this.addColumn('nodes', 'stale', 'INTEGER NOT NULL DEFAULT 0');
|
|
56
66
|
try {
|
|
57
67
|
chmodSync(path, 0o600);
|
|
58
68
|
}
|
|
@@ -60,6 +70,11 @@ export class State {
|
|
|
60
70
|
/* Windows has no chmod that means anything */
|
|
61
71
|
}
|
|
62
72
|
}
|
|
73
|
+
addColumn(table, column, type) {
|
|
74
|
+
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
75
|
+
if (!cols.some(c => c.name === column))
|
|
76
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
77
|
+
}
|
|
63
78
|
stmt(sql) {
|
|
64
79
|
let s = this.cache.get(sql);
|
|
65
80
|
if (!s) {
|
|
@@ -142,13 +157,58 @@ export class State {
|
|
|
142
157
|
/** Records that `path` was indexed at `hash` and now lives in `nodeIds`. */
|
|
143
158
|
recordFile(repo, path, hash, nodeIds, now = Date.now()) {
|
|
144
159
|
this.transaction(() => {
|
|
145
|
-
this.stmt(`INSERT INTO files (repo, path, hash, dirty, indexed_at) VALUES (?, ?, ?, 0,
|
|
146
|
-
ON CONFLICT(repo, path) DO UPDATE SET hash = excluded.hash, dirty = 0, indexed_at = excluded.indexed_at`).run(repo, path, hash, now);
|
|
160
|
+
this.stmt(`INSERT INTO files (repo, path, hash, dirty, indexed_at, pushed) VALUES (?, ?, ?, 0, ?, 0)
|
|
161
|
+
ON CONFLICT(repo, path) DO UPDATE SET hash = excluded.hash, dirty = 0, indexed_at = excluded.indexed_at, pushed = 0`).run(repo, path, hash, now);
|
|
147
162
|
for (const id of nodeIds) {
|
|
148
163
|
this.stmt('INSERT OR IGNORE INTO links (repo, path, node_id) VALUES (?, ?, ?)').run(repo, path, id);
|
|
149
164
|
}
|
|
150
165
|
});
|
|
151
166
|
}
|
|
167
|
+
manifestOf(repo) {
|
|
168
|
+
const out = new Map();
|
|
169
|
+
for (const [path, row] of this.files(repo))
|
|
170
|
+
out.set(path, { hash: row.hash, nodeIds: this.nodesOf(repo, path) });
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
unpushedFiles(repo) {
|
|
174
|
+
const rows = this.stmt('SELECT path, hash FROM files WHERE repo = ? AND pushed = 0 ORDER BY path').all(repo);
|
|
175
|
+
return rows.map(r => ({ path: r.path, hash: r.hash, nodeIds: this.nodesOf(repo, r.path) }));
|
|
176
|
+
}
|
|
177
|
+
markPushed(repo, paths) {
|
|
178
|
+
this.transaction(() => {
|
|
179
|
+
for (const p of paths)
|
|
180
|
+
this.stmt('UPDATE files SET pushed = 1 WHERE repo = ? AND path = ?').run(repo, p);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Takes the server's manifest as the truth for every row already pushed. Unpushed rows are this
|
|
185
|
+
* machine's work the server hasn't seen, so they stay; dirty flags are local knowledge, so they stay.
|
|
186
|
+
*/
|
|
187
|
+
replaceManifest(repo, rows) {
|
|
188
|
+
this.transaction(() => {
|
|
189
|
+
const dirty = new Set(this.dirtyFiles(repo));
|
|
190
|
+
const unpushed = new Set(this.unpushedFiles(repo).map(f => f.path));
|
|
191
|
+
for (const path of this.files(repo).keys()) {
|
|
192
|
+
if (unpushed.has(path))
|
|
193
|
+
continue;
|
|
194
|
+
this.stmt('DELETE FROM files WHERE repo = ? AND path = ?').run(repo, path);
|
|
195
|
+
this.stmt('DELETE FROM links WHERE repo = ? AND path = ?').run(repo, path);
|
|
196
|
+
}
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
if (unpushed.has(row.path))
|
|
199
|
+
continue;
|
|
200
|
+
this.stmt('INSERT INTO files (repo, path, hash, dirty, indexed_at, pushed) VALUES (?, ?, ?, ?, NULL, 1)').run(repo, row.path, row.hash, dirty.has(row.path) ? 1 : 0);
|
|
201
|
+
for (const id of row.nodeIds)
|
|
202
|
+
this.stmt('INSERT OR IGNORE INTO links (repo, path, node_id) VALUES (?, ?, ?)').run(repo, row.path, id);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
renameFile(repo, from, to) {
|
|
207
|
+
this.transaction(() => {
|
|
208
|
+
this.stmt('UPDATE files SET path = ?, pushed = 0 WHERE repo = ? AND path = ?').run(to, repo, from);
|
|
209
|
+
this.stmt('UPDATE links SET path = ? WHERE repo = ? AND path = ?').run(to, repo, from);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
152
212
|
forgetFile(repo, path) {
|
|
153
213
|
this.transaction(() => {
|
|
154
214
|
this.stmt('DELETE FROM files WHERE repo = ? AND path = ?').run(repo, path);
|
|
@@ -182,21 +242,21 @@ export class State {
|
|
|
182
242
|
this.stmt('DELETE FROM links WHERE node_id = ?').run(nodeId);
|
|
183
243
|
});
|
|
184
244
|
}
|
|
185
|
-
/** Replaces the synced set: rows
|
|
186
|
-
replaceNodes(rows) {
|
|
245
|
+
/** Replaces the synced set for one workspace: rows of that workspace not in the new set are dropped. */
|
|
246
|
+
replaceNodes(rows, workspace = '') {
|
|
187
247
|
this.transaction(() => {
|
|
188
|
-
const known = new Map(this.stmt('SELECT node_id, tokens FROM nodes').all().map(r => [r.node_id, r.tokens]));
|
|
189
|
-
this.
|
|
248
|
+
const known = new Map(this.stmt('SELECT node_id, tokens FROM nodes WHERE workspace = ?').all(workspace).map(r => [r.node_id, r.tokens]));
|
|
249
|
+
this.stmt('DELETE FROM nodes WHERE workspace = ?').run(workspace);
|
|
190
250
|
for (const row of rows) {
|
|
191
|
-
this.stmt('INSERT INTO nodes (node_id, ref, title, type, tokens, touched_at) VALUES (?, ?, ?, ?, ?, ?)').run(row.node_id, row.ref, row.title, row.type, row.tokens ?? known.get(row.node_id) ?? null, row.touched_at);
|
|
251
|
+
this.stmt('INSERT OR REPLACE INTO nodes (node_id, ref, title, type, tokens, touched_at, workspace, stale) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(row.node_id, row.ref, row.title, row.type, row.tokens ?? known.get(row.node_id) ?? null, row.touched_at, workspace, row.stale ?? 0);
|
|
192
252
|
}
|
|
193
253
|
});
|
|
194
254
|
}
|
|
195
|
-
recentNodes(limit) {
|
|
196
|
-
return this.stmt('SELECT node_id, ref, title, type, tokens, touched_at FROM nodes ORDER BY touched_at DESC LIMIT ?').all(limit);
|
|
255
|
+
recentNodes(limit, workspace = '') {
|
|
256
|
+
return this.stmt('SELECT node_id, ref, title, type, tokens, touched_at, stale FROM nodes WHERE workspace = ? ORDER BY touched_at DESC LIMIT ?').all(workspace, limit);
|
|
197
257
|
}
|
|
198
|
-
nodeCount() {
|
|
199
|
-
return this.stmt('SELECT COUNT(*) AS n FROM nodes').get().n;
|
|
258
|
+
nodeCount(workspace = '') {
|
|
259
|
+
return this.stmt('SELECT COUNT(*) AS n FROM nodes WHERE workspace = ?').get(workspace).n;
|
|
200
260
|
}
|
|
201
261
|
}
|
|
202
262
|
/** Opens the state, or returns undefined when it cannot be opened, so a hook never fails a session. */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const key = (slug) => `sync_note:${slug}`;
|
|
2
|
+
export function readNote(state, slug) {
|
|
3
|
+
const raw = state.getMeta(key(slug));
|
|
4
|
+
if (!raw)
|
|
5
|
+
return undefined;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(raw);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function writeNote(state, slug, note) {
|
|
14
|
+
state.setMeta(key(slug), JSON.stringify({ ...note, at: Date.now() }));
|
|
15
|
+
}
|
|
16
|
+
export function clearNote(state, slug) {
|
|
17
|
+
state.setMeta(key(slug), '');
|
|
18
|
+
}
|
|
19
|
+
export function maxRewriteFiles() {
|
|
20
|
+
const n = Number(process.env['GIGARAG_SYNC_MAX_FILES']);
|
|
21
|
+
return Number.isInteger(n) && n >= 0 ? n : 40;
|
|
22
|
+
}
|
|
23
|
+
const short = (commit) => (commit && /^[0-9a-f]{7,64}$/.test(commit) ? commit.slice(0, 7) : 'an unknown commit');
|
|
24
|
+
const count = (n) => (Number.isInteger(n) && n >= 0 ? n : 0);
|
|
25
|
+
const clean = (s) => (s ?? 'no reason given').replace(/[^\x20-\x7e]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
26
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
27
|
+
export function renderNote(note, slug, maxFiles) {
|
|
28
|
+
const c = short(note.commit);
|
|
29
|
+
const n = count(note.files);
|
|
30
|
+
switch (note.kind) {
|
|
31
|
+
case 'needs_approval':
|
|
32
|
+
return `${plural(n, 'file', 'files')} in this repository (${slug}) changed at ${c}, more than the ${maxFiles} a background sync rewrites on its own. Their memos are marked stale. Ask the user whether to run /gigasync now; it rewrites them with this session's model.`;
|
|
33
|
+
case 'no_agent':
|
|
34
|
+
return `${plural(n, 'memo', 'memos')} in this repository (${slug}) are marked stale since ${c} and no background agent could rewrite them. /gigasync rewrites them.`;
|
|
35
|
+
case 'dirty':
|
|
36
|
+
return `${plural(n, 'memo', 'memos')} in this repository (${slug}) are marked stale since ${c}. The background rewrite waits for a clean working tree, because it reads files from disk. /gigasync rewrites them now.`;
|
|
37
|
+
case 'failed':
|
|
38
|
+
return `The background rewrite of ${slug} stopped: ${clean(note.detail)}. Its memos are still marked stale. /gigasync rewrites them, and the log is at ~/.gigarag/logs/sync-${slug}.log.`;
|
|
39
|
+
case 'behind':
|
|
40
|
+
return `This checkout of ${slug} doesn't contain ${c}, the commit GigaRAG last synced, so automatic sync is paused. Pulling the branch resumes it; if this checkout is meant to replace what GigaRAG has (an amend, a rebase or a deliberate rewrite), gigarag sync begin --force syncs it instead.`;
|
|
41
|
+
case 'held':
|
|
42
|
+
return `Another machine was syncing ${slug} when this one tried. Nothing to do; the next commit tries again.`;
|
|
43
|
+
case 'mass_delete':
|
|
44
|
+
return `${plural(n, 'file', 'files')} in ${slug} look deleted, more than half of what was indexed, so no memos were deleted. If the files really are gone, ask the user whether to run gigarag sync begin --force, which deletes their memos; if not, the checkout needs checking.`;
|
|
45
|
+
case 'deferred':
|
|
46
|
+
return `An interactive /gigasync was running for ${slug} on this machine when the background sync tried to run after a commit. The sync is queued, not dropped, and runs once that session finishes; gigarag sync end clears a session that already finished but never called it.`;
|
|
47
|
+
case 'untrusted':
|
|
48
|
+
return `This repository's .gigarag.json links it to workspace ${clean(note.detail)}, which has not been trusted, so no sync ran here and none will until it is. If you placed or reviewed that file yourself, run: gigarag trust. To use a different workspace instead: gigarag use <workspace>.`;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/cli/sync/plan.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a sync does without a model, decided from two maps: what the manifest
|
|
3
|
+
* says was indexed, and what is on disk now. Pure, so every rule here has a
|
|
4
|
+
* test that doesn't need git or a server.
|
|
5
|
+
*/
|
|
6
|
+
/** More than this many gone paths, and more than half the manifest, is what a broken checkout looks like. */
|
|
7
|
+
export const MASS_DELETE_MIN = 20;
|
|
8
|
+
function byHash(paths, hashOf) {
|
|
9
|
+
const m = new Map();
|
|
10
|
+
for (const p of paths) {
|
|
11
|
+
const h = hashOf(p);
|
|
12
|
+
m.set(h, [...(m.get(h) ?? []), p]);
|
|
13
|
+
}
|
|
14
|
+
return m;
|
|
15
|
+
}
|
|
16
|
+
export function planSync(input) {
|
|
17
|
+
const { known, current } = input;
|
|
18
|
+
const gone = input.truncated ? [] : [...known.keys()].filter(p => !current.has(p) && !(input.atHead?.has(p) ?? false));
|
|
19
|
+
const added = [...current.keys()].filter(p => !known.has(p));
|
|
20
|
+
// A rename only when the hash is unique on both sides. Two copies of one LICENSE are not evidence.
|
|
21
|
+
const goneByHash = byHash(gone, p => known.get(p).hash);
|
|
22
|
+
const addedByHash = byHash(added, p => current.get(p));
|
|
23
|
+
const renames = [];
|
|
24
|
+
for (const [hash, from] of goneByHash) {
|
|
25
|
+
const to = addedByHash.get(hash);
|
|
26
|
+
if (from.length === 1 && to?.length === 1)
|
|
27
|
+
renames.push({ from: from[0], to: to[0], hash, nodeIds: known.get(from[0]).nodeIds });
|
|
28
|
+
}
|
|
29
|
+
const renamedTo = new Map(renames.map(r => [r.from, r.to]));
|
|
30
|
+
const newPaths = new Set(renames.map(r => r.to));
|
|
31
|
+
const trulyGone = gone.filter(p => !renamedTo.has(p));
|
|
32
|
+
const suspicious = trulyGone.length > MASS_DELETE_MIN && trulyGone.length * 2 > known.size;
|
|
33
|
+
// Which live paths feed each memo once renames are applied.
|
|
34
|
+
const feeders = new Map();
|
|
35
|
+
for (const [path, file] of known) {
|
|
36
|
+
const live = renamedTo.get(path) ?? path;
|
|
37
|
+
for (const id of file.nodeIds)
|
|
38
|
+
feeders.set(id, [...(feeders.get(id) ?? []), live]);
|
|
39
|
+
}
|
|
40
|
+
const goneSet = new Set(trulyGone);
|
|
41
|
+
const deleteNodeIds = new Set();
|
|
42
|
+
const deletedPaths = [];
|
|
43
|
+
const partial = [];
|
|
44
|
+
if (!suspicious || input.allowMassDelete) {
|
|
45
|
+
for (const path of trulyGone) {
|
|
46
|
+
const ids = known.get(path).nodeIds;
|
|
47
|
+
const orphaned = ids.filter(id => (feeders.get(id) ?? []).every(p => goneSet.has(p)));
|
|
48
|
+
for (const id of orphaned)
|
|
49
|
+
deleteNodeIds.add(id);
|
|
50
|
+
const shared = ids.filter(id => !orphaned.includes(id));
|
|
51
|
+
if (shared.length > 0)
|
|
52
|
+
partial.push({ path, nodeIds: shared });
|
|
53
|
+
else
|
|
54
|
+
deletedPaths.push(path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const changed = [...current].filter(([p, h]) => known.has(p) && known.get(p).hash !== h).map(([p]) => p);
|
|
58
|
+
const rewrite = [...changed, ...added.filter(p => !newPaths.has(p))].sort();
|
|
59
|
+
const stale = new Set();
|
|
60
|
+
for (const p of changed)
|
|
61
|
+
for (const id of known.get(p).nodeIds)
|
|
62
|
+
stale.add(id);
|
|
63
|
+
for (const p of partial)
|
|
64
|
+
for (const id of p.nodeIds)
|
|
65
|
+
stale.add(id);
|
|
66
|
+
return {
|
|
67
|
+
renames,
|
|
68
|
+
deleteNodeIds: [...deleteNodeIds].sort(),
|
|
69
|
+
deletedPaths: deletedPaths.sort(),
|
|
70
|
+
partial,
|
|
71
|
+
stale: [...stale].sort(),
|
|
72
|
+
rewrite,
|
|
73
|
+
suspicious,
|
|
74
|
+
goneCount: trulyGone.length,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const RECORD_BATCH = 500;
|
|
2
|
+
const STALE_BATCH = 200;
|
|
3
|
+
function chunks(items, size) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (let i = 0; i < items.length; i += size)
|
|
6
|
+
out.push(items.slice(i, i + size));
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
/** The bucket whose slug is exactly `slug`. list_buckets filters by substring, so the match is checked here. */
|
|
10
|
+
export async function findBucketId(rag, slug) {
|
|
11
|
+
const page = await rag.listBuckets({ q: slug, limit: 50 });
|
|
12
|
+
const hit = page.items.find(b => b['slug'] === slug);
|
|
13
|
+
return typeof hit?.['id'] === 'string' ? hit['id'] : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** repo_sync for one bucket, with core's limits applied by batching rather than by the caller. */
|
|
16
|
+
export class RepoSyncRemote {
|
|
17
|
+
rag;
|
|
18
|
+
bucketId;
|
|
19
|
+
constructor(rag, bucketId) {
|
|
20
|
+
this.rag = rag;
|
|
21
|
+
this.bucketId = bucketId;
|
|
22
|
+
}
|
|
23
|
+
call(action, args = {}) {
|
|
24
|
+
return this.rag.callTool('repo_sync', { bucket_id: this.bucketId, action, ...args });
|
|
25
|
+
}
|
|
26
|
+
state() {
|
|
27
|
+
return this.call('state');
|
|
28
|
+
}
|
|
29
|
+
async manifest() {
|
|
30
|
+
const all = [];
|
|
31
|
+
let after;
|
|
32
|
+
do {
|
|
33
|
+
const page = await this.call('manifest', { limit: 1000, ...(after ? { after } : {}) });
|
|
34
|
+
all.push(...page.items);
|
|
35
|
+
after = page.next_cursor ?? undefined;
|
|
36
|
+
} while (after);
|
|
37
|
+
return all;
|
|
38
|
+
}
|
|
39
|
+
claim(holder, token) {
|
|
40
|
+
return this.call('claim', { holder, ttl_seconds: 600, ...(token ? { token } : {}) });
|
|
41
|
+
}
|
|
42
|
+
/** Never throws: a lease that can't be released lapses on its own in ten minutes. */
|
|
43
|
+
async release(token) {
|
|
44
|
+
try {
|
|
45
|
+
await this.call('release', { token });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* lapses */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async record(upserts, removes) {
|
|
52
|
+
const entries = [
|
|
53
|
+
...upserts.map(u => ({ up: { path: u.path, hash: u.hash, node_ids: u.nodeIds } })),
|
|
54
|
+
...removes.map(rm => ({ rm })),
|
|
55
|
+
];
|
|
56
|
+
for (const batch of chunks(entries, RECORD_BATCH)) {
|
|
57
|
+
await this.call('record', {
|
|
58
|
+
upserts: batch.flatMap(e => ('up' in e ? [e.up] : [])),
|
|
59
|
+
removes: batch.flatMap(e => ('rm' in e ? [e.rm] : [])),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async advance(token, commit, branch) {
|
|
64
|
+
await this.call('advance', { token, commit, ...(branch ? { branch } : {}) });
|
|
65
|
+
}
|
|
66
|
+
async markStale(nodeIds, commit) {
|
|
67
|
+
let marked = 0;
|
|
68
|
+
for (const batch of chunks(nodeIds, STALE_BATCH))
|
|
69
|
+
marked += (await this.call('mark_stale', { node_ids: batch, commit })).marked;
|
|
70
|
+
return marked;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
|
|
3
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* The GigaRAG MCP tools the gigasync/gigaindex flow actually calls (see
|
|
6
|
+
* plugins/claude-code/agents/gigarag-indexer.md and commands/gigasync.md): find or create the
|
|
7
|
+
* bucket and its threads, write and remove memos, and resolve a link's ref. Nothing here can
|
|
8
|
+
* delete a bucket or thread, link two memos, or read outside what `gigarag scan` already named.
|
|
9
|
+
*/
|
|
10
|
+
const GIGARAG_TOOLS = ['list_buckets', 'create_bucket', 'list_threads', 'create_thread', 'find_nodes', 'create_node', 'update_node', 'delete_node'];
|
|
11
|
+
/**
|
|
12
|
+
* Starts Claude Code to rewrite stale memos, with nothing but the GigaRAG tools the flow needs,
|
|
13
|
+
* reading tools and three gigarag subcommands. dontAsk denies anything else rather than waiting
|
|
14
|
+
* for a person who isn't there. Both prefixes are granted per tool, never as `mcp__*__*`, because
|
|
15
|
+
* the plugin and the bare MCP server register the same tool names under different prefixes and an
|
|
16
|
+
* unattended agent should not gain whatever else either server exposes later.
|
|
17
|
+
*/
|
|
18
|
+
export const CLAUDE_ARGS = [
|
|
19
|
+
'/gigarag:gigasync',
|
|
20
|
+
'--print',
|
|
21
|
+
'--permission-mode',
|
|
22
|
+
'dontAsk',
|
|
23
|
+
'--max-turns',
|
|
24
|
+
'300',
|
|
25
|
+
'--output-format',
|
|
26
|
+
'json',
|
|
27
|
+
'--no-session-persistence',
|
|
28
|
+
'--allowedTools',
|
|
29
|
+
...GIGARAG_TOOLS.map(t => `mcp__plugin_gigarag_gigarag__${t}`),
|
|
30
|
+
...GIGARAG_TOOLS.map(t => `mcp__gigarag__${t}`),
|
|
31
|
+
'Read',
|
|
32
|
+
'Grep',
|
|
33
|
+
'Glob',
|
|
34
|
+
'Agent',
|
|
35
|
+
'Bash(gigarag scan *)',
|
|
36
|
+
'Bash(gigarag record *)',
|
|
37
|
+
'Bash(gigarag sync *)',
|
|
38
|
+
'--disallowedTools',
|
|
39
|
+
'Write',
|
|
40
|
+
'Edit',
|
|
41
|
+
'NotebookEdit',
|
|
42
|
+
'WebFetch',
|
|
43
|
+
'WebSearch',
|
|
44
|
+
];
|
|
45
|
+
/** One cmd.exe command line, every part double-quoted. Refuses what quoting can't make literal. */
|
|
46
|
+
export function cmdLine(parts) {
|
|
47
|
+
for (const p of parts) {
|
|
48
|
+
if (/["%\r\n]/.test(p))
|
|
49
|
+
throw new Error(`An argument for claude holds a character cmd.exe would read as syntax, so the rewrite did not start: ${p}`);
|
|
50
|
+
}
|
|
51
|
+
return parts.map(p => `"${p}"`).join(' ');
|
|
52
|
+
}
|
|
53
|
+
/** GIGARAG_CLAUDE (a path, for tests and odd installs), then claude on PATH, preferring an .exe on Windows. */
|
|
54
|
+
export function findClaude(env, platform) {
|
|
55
|
+
const override = env['GIGARAG_CLAUDE'];
|
|
56
|
+
if (override)
|
|
57
|
+
return existsSync(override) ? override : undefined;
|
|
58
|
+
const names = platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude'];
|
|
59
|
+
for (const dir of (env['PATH'] ?? env['Path'] ?? '').split(delimiter).filter(Boolean)) {
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const candidate = join(dir, name);
|
|
62
|
+
if (existsSync(candidate))
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function claudeInvocation(path, platform) {
|
|
69
|
+
if (/\.(mjs|js)$/i.test(path))
|
|
70
|
+
return { command: process.execPath, args: [path, ...CLAUDE_ARGS] };
|
|
71
|
+
if (platform === 'win32' && /\.(cmd|bat)$/i.test(path)) {
|
|
72
|
+
return { command: process.env['ComSpec'] ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${cmdLine([path, ...CLAUDE_ARGS])}"`], verbatim: true };
|
|
73
|
+
}
|
|
74
|
+
return { command: path, args: CLAUDE_ARGS };
|
|
75
|
+
}
|
|
76
|
+
function killTree(pid) {
|
|
77
|
+
if (!pid)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
if (process.platform === 'win32')
|
|
81
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
82
|
+
else
|
|
83
|
+
process.kill(-pid, 'SIGTERM');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 'SIGKILL');
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* already gone */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function runRewriter(inv, cwd, logPath, opts) {
|
|
95
|
+
mkdirSync(dirname(logPath), { recursive: true, mode: 0o700 });
|
|
96
|
+
const fd = openSync(logPath, 'w', 0o600);
|
|
97
|
+
return new Promise(resolvePromise => {
|
|
98
|
+
let settled = false;
|
|
99
|
+
let timedOut = false;
|
|
100
|
+
const child = spawn(inv.command, inv.args, {
|
|
101
|
+
cwd,
|
|
102
|
+
env: opts.env,
|
|
103
|
+
stdio: ['ignore', fd, fd],
|
|
104
|
+
windowsHide: true,
|
|
105
|
+
windowsVerbatimArguments: inv.verbatim ?? false,
|
|
106
|
+
detached: process.platform !== 'win32',
|
|
107
|
+
});
|
|
108
|
+
const tick = setInterval(() => void opts.onTick().catch(() => { }), opts.tickMs);
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
timedOut = true;
|
|
111
|
+
killTree(child.pid);
|
|
112
|
+
}, opts.timeoutMs);
|
|
113
|
+
const done = (code) => {
|
|
114
|
+
if (settled)
|
|
115
|
+
return;
|
|
116
|
+
settled = true;
|
|
117
|
+
clearInterval(tick);
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
resolvePromise({ code, timedOut });
|
|
121
|
+
};
|
|
122
|
+
child.on('error', () => done(null));
|
|
123
|
+
child.on('close', code => done(code));
|
|
124
|
+
});
|
|
125
|
+
}
|
package/cli/sync/slug.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isTrusted, readBinding } from '../binding.js';
|
|
2
|
+
import { findRepoRoot, localSlug, readOriginUrl, repoInfo, slugFromRemote } from '../scan/repo.js';
|
|
3
|
+
/**
|
|
4
|
+
* Which bucket a repository syncs into. The only file in sync that reads the
|
|
5
|
+
* binding from the sharing levels spec: with one that is trusted, its bucket and workspace;
|
|
6
|
+
* without, the slug from the remote, which is how everything worked before. An untrusted
|
|
7
|
+
* binding's bucket and workspace are both ignored, not just its workspace: reporting
|
|
8
|
+
* `untrustedWorkspace` instead so callers refuse rather than sync under the unreviewed name.
|
|
9
|
+
* Kept free of the SDK so a hook can call it without loading a client.
|
|
10
|
+
*
|
|
11
|
+
* Deviation from the plan: the sharing levels binding is `readBinding(start): { binding, root } | undefined`
|
|
12
|
+
* with `binding.workspace` (a uuid), not `readBinding(root): { workspaceId, bucket } | undefined`. Adapted here.
|
|
13
|
+
*/
|
|
14
|
+
export function syncSlug(start) {
|
|
15
|
+
const { root, git } = findRepoRoot(start);
|
|
16
|
+
const found = readBinding(root);
|
|
17
|
+
if (found && !isTrusted(found.root, found.binding.workspace)) {
|
|
18
|
+
// Never `repoInfo(root).slug`: it reads the same binding and would still hand back the
|
|
19
|
+
// untrusted file's own bucket name. Recomputed the way an unbound repository resolves,
|
|
20
|
+
// ignoring the binding entirely, same as `workspaceId` below.
|
|
21
|
+
const remote = git ? readOriginUrl(root) : undefined;
|
|
22
|
+
const slug = (remote && slugFromRemote(remote)) || localSlug(root);
|
|
23
|
+
return { root, git, slug, untrustedWorkspace: found.binding.workspace };
|
|
24
|
+
}
|
|
25
|
+
// Trusted or absent: delegate entirely to repoInfo, which applies the same trust check
|
|
26
|
+
// (`trustedBinding` in binding.ts) rather than re-reading `found.binding.bucket` here, so the
|
|
27
|
+
// two functions cannot drift on what a trusted binding hands back.
|
|
28
|
+
const info = repoInfo(root);
|
|
29
|
+
return { root, git, slug: info.slug, ...(info.workspace ? { workspaceId: info.workspace } : {}) };
|
|
30
|
+
}
|