@residue/cli 0.0.2 → 0.0.4
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/CHANGELOG.md +14 -0
- package/README.md +25 -1
- package/dist/index.js +551 -32
- package/package.json +1 -1
- package/src/commands/clear.ts +42 -0
- package/src/commands/search.ts +266 -0
- package/src/commands/status.ts +180 -0
- package/src/commands/sync.ts +85 -1
- package/src/index.ts +28 -1
- package/src/lib/search-text.ts +257 -0
- package/test/lib/search-text.test.ts +366 -0
package/dist/index.js
CHANGED
|
@@ -3622,10 +3622,40 @@ function capture() {
|
|
|
3622
3622
|
});
|
|
3623
3623
|
}
|
|
3624
3624
|
|
|
3625
|
+
// src/commands/clear.ts
|
|
3626
|
+
var log2 = createLogger("clear");
|
|
3627
|
+
function clear(opts) {
|
|
3628
|
+
return safeTry(async function* () {
|
|
3629
|
+
const projectRoot = yield* getProjectRoot();
|
|
3630
|
+
const pendingPath = yield* getPendingPath(projectRoot);
|
|
3631
|
+
const sessions = yield* readPending(pendingPath);
|
|
3632
|
+
if (sessions.length === 0) {
|
|
3633
|
+
log2.info("No pending sessions to clear.");
|
|
3634
|
+
return ok(undefined);
|
|
3635
|
+
}
|
|
3636
|
+
if (opts?.id) {
|
|
3637
|
+
const targetId = opts.id;
|
|
3638
|
+
const isFound = sessions.some((s) => s.id === targetId);
|
|
3639
|
+
if (!isFound) {
|
|
3640
|
+
log2.info(`Session ${targetId} not found in pending queue.`);
|
|
3641
|
+
return ok(undefined);
|
|
3642
|
+
}
|
|
3643
|
+
const remaining = sessions.filter((s) => s.id !== targetId);
|
|
3644
|
+
yield* writePending({ path: pendingPath, sessions: remaining });
|
|
3645
|
+
log2.info(`Cleared session ${targetId}.`);
|
|
3646
|
+
return ok(undefined);
|
|
3647
|
+
}
|
|
3648
|
+
const count = sessions.length;
|
|
3649
|
+
yield* writePending({ path: pendingPath, sessions: [] });
|
|
3650
|
+
log2.info(`Cleared ${count} pending session(s).`);
|
|
3651
|
+
return ok(undefined);
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3625
3655
|
// src/commands/hook.ts
|
|
3626
3656
|
import { mkdir as mkdir2, readFile, rm, stat, writeFile } from "fs/promises";
|
|
3627
3657
|
import { join as join2 } from "path";
|
|
3628
|
-
var
|
|
3658
|
+
var log3 = createLogger("hook");
|
|
3629
3659
|
function readStdin() {
|
|
3630
3660
|
return ResultAsync.fromPromise((async () => {
|
|
3631
3661
|
const chunks = [];
|
|
@@ -3722,7 +3752,7 @@ function handleSessionStart(opts) {
|
|
|
3722
3752
|
message: "Failed to write hook state file",
|
|
3723
3753
|
code: "IO_ERROR"
|
|
3724
3754
|
}));
|
|
3725
|
-
|
|
3755
|
+
log3.debug("session started for claude-code");
|
|
3726
3756
|
return ok(undefined);
|
|
3727
3757
|
});
|
|
3728
3758
|
}
|
|
@@ -3754,7 +3784,7 @@ function handleSessionEnd(opts) {
|
|
|
3754
3784
|
message: "Failed to remove hook state file",
|
|
3755
3785
|
code: "IO_ERROR"
|
|
3756
3786
|
}));
|
|
3757
|
-
|
|
3787
|
+
log3.debug("session %s ended", trimmedId);
|
|
3758
3788
|
return ok(undefined);
|
|
3759
3789
|
});
|
|
3760
3790
|
}
|
|
@@ -3785,7 +3815,7 @@ import {
|
|
|
3785
3815
|
writeFile as writeFile2
|
|
3786
3816
|
} from "fs/promises";
|
|
3787
3817
|
import { join as join3 } from "path";
|
|
3788
|
-
var
|
|
3818
|
+
var log4 = createLogger("init");
|
|
3789
3819
|
var POST_COMMIT_LINE = "residue capture >/dev/null 2>&1 &";
|
|
3790
3820
|
var PRE_PUSH_LINE = 'residue sync --remote-url "$2"';
|
|
3791
3821
|
function installHook(opts) {
|
|
@@ -3876,9 +3906,9 @@ function init() {
|
|
|
3876
3906
|
}),
|
|
3877
3907
|
ensureGitignore(projectRoot)
|
|
3878
3908
|
]);
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3909
|
+
log4.info("Initialized residue in this repository.");
|
|
3910
|
+
log4.info(` ${postCommit}`);
|
|
3911
|
+
log4.info(` ${prePush}`);
|
|
3882
3912
|
return ok(undefined);
|
|
3883
3913
|
});
|
|
3884
3914
|
}
|
|
@@ -3937,7 +3967,7 @@ function resolveConfig() {
|
|
|
3937
3967
|
}
|
|
3938
3968
|
|
|
3939
3969
|
// src/commands/login.ts
|
|
3940
|
-
var
|
|
3970
|
+
var log5 = createLogger("login");
|
|
3941
3971
|
function login(opts) {
|
|
3942
3972
|
if (!opts.url.startsWith("http://") && !opts.url.startsWith("https://")) {
|
|
3943
3973
|
return errAsync(new CliError({
|
|
@@ -3949,17 +3979,164 @@ function login(opts) {
|
|
|
3949
3979
|
const config = { worker_url: cleanUrl, token: opts.token };
|
|
3950
3980
|
if (opts.isLocal) {
|
|
3951
3981
|
return getProjectRoot().andThen((projectRoot) => writeLocalConfig({ projectRoot, config }).map(() => {
|
|
3952
|
-
|
|
3982
|
+
log5.info(`Logged in to ${cleanUrl} (project-local config)`);
|
|
3953
3983
|
}));
|
|
3954
3984
|
}
|
|
3955
3985
|
return writeConfig(config).map(() => {
|
|
3956
|
-
|
|
3986
|
+
log5.info(`Logged in to ${cleanUrl}`);
|
|
3957
3987
|
});
|
|
3958
3988
|
}
|
|
3959
3989
|
|
|
3990
|
+
// src/lib/search-text.ts
|
|
3991
|
+
function buildSearchText(opts) {
|
|
3992
|
+
const header = [
|
|
3993
|
+
`Session: ${opts.metadata.sessionId}`,
|
|
3994
|
+
`Agent: ${opts.metadata.agent}`,
|
|
3995
|
+
opts.metadata.commits.length > 0 ? `Commits: ${opts.metadata.commits.join(", ")}` : null,
|
|
3996
|
+
opts.metadata.branch ? `Branch: ${opts.metadata.branch}` : null,
|
|
3997
|
+
opts.metadata.repo ? `Repo: ${opts.metadata.repo}` : null
|
|
3998
|
+
].filter(Boolean).join(`
|
|
3999
|
+
`);
|
|
4000
|
+
const body = opts.lines.map((line) => `[${line.role}] ${line.text}`).join(`
|
|
4001
|
+
`);
|
|
4002
|
+
return `${header}
|
|
4003
|
+
|
|
4004
|
+
${body}
|
|
4005
|
+
`;
|
|
4006
|
+
}
|
|
4007
|
+
function extractClaudeCode(raw) {
|
|
4008
|
+
const lines = [];
|
|
4009
|
+
if (!raw.trim())
|
|
4010
|
+
return lines;
|
|
4011
|
+
const entries = [];
|
|
4012
|
+
for (const line of raw.split(`
|
|
4013
|
+
`)) {
|
|
4014
|
+
if (!line.trim())
|
|
4015
|
+
continue;
|
|
4016
|
+
try {
|
|
4017
|
+
entries.push(JSON.parse(line));
|
|
4018
|
+
} catch {}
|
|
4019
|
+
}
|
|
4020
|
+
for (const entry of entries) {
|
|
4021
|
+
if (entry.isMeta || entry.isSidechain)
|
|
4022
|
+
continue;
|
|
4023
|
+
if (entry.type === "user") {
|
|
4024
|
+
const content = entry.message?.content;
|
|
4025
|
+
if (!content)
|
|
4026
|
+
continue;
|
|
4027
|
+
if (typeof content === "string") {
|
|
4028
|
+
const trimmed = content.trim();
|
|
4029
|
+
if (trimmed)
|
|
4030
|
+
lines.push({ role: "human", text: trimmed });
|
|
4031
|
+
} else if (Array.isArray(content)) {
|
|
4032
|
+
const hasToolResult = content.some((b) => b.type === "tool_result");
|
|
4033
|
+
if (hasToolResult)
|
|
4034
|
+
continue;
|
|
4035
|
+
const text = content.filter((b) => b.type === "text" && b.text).map((b) => b.text).join(`
|
|
4036
|
+
`).trim();
|
|
4037
|
+
if (text)
|
|
4038
|
+
lines.push({ role: "human", text });
|
|
4039
|
+
}
|
|
4040
|
+
} else if (entry.type === "assistant") {
|
|
4041
|
+
const content = entry.message?.content;
|
|
4042
|
+
if (!Array.isArray(content))
|
|
4043
|
+
continue;
|
|
4044
|
+
for (const block of content) {
|
|
4045
|
+
if (block.type === "text" && block.text) {
|
|
4046
|
+
const trimmed = block.text.trim();
|
|
4047
|
+
if (trimmed)
|
|
4048
|
+
lines.push({ role: "assistant", text: trimmed });
|
|
4049
|
+
} else if (block.type === "tool_use" && block.name) {
|
|
4050
|
+
const desc = summarizeToolInput(block.name, block.input);
|
|
4051
|
+
lines.push({ role: "tool", text: desc });
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
return lines;
|
|
4057
|
+
}
|
|
4058
|
+
function extractPi(raw) {
|
|
4059
|
+
const lines = [];
|
|
4060
|
+
if (!raw.trim())
|
|
4061
|
+
return lines;
|
|
4062
|
+
const entries = [];
|
|
4063
|
+
for (const line of raw.split(`
|
|
4064
|
+
`)) {
|
|
4065
|
+
if (!line.trim())
|
|
4066
|
+
continue;
|
|
4067
|
+
try {
|
|
4068
|
+
entries.push(JSON.parse(line));
|
|
4069
|
+
} catch {}
|
|
4070
|
+
}
|
|
4071
|
+
for (const entry of entries) {
|
|
4072
|
+
if (entry.type !== "message")
|
|
4073
|
+
continue;
|
|
4074
|
+
const msg = entry.message;
|
|
4075
|
+
if (!msg)
|
|
4076
|
+
continue;
|
|
4077
|
+
if (msg.role === "user") {
|
|
4078
|
+
const content = msg.content;
|
|
4079
|
+
if (!content)
|
|
4080
|
+
continue;
|
|
4081
|
+
if (typeof content === "string") {
|
|
4082
|
+
const trimmed = content.trim();
|
|
4083
|
+
if (trimmed)
|
|
4084
|
+
lines.push({ role: "human", text: trimmed });
|
|
4085
|
+
} else if (Array.isArray(content)) {
|
|
4086
|
+
const text = content.filter((b) => b.type === "text" && b.text).map((b) => b.text).join(`
|
|
4087
|
+
`).trim();
|
|
4088
|
+
if (text)
|
|
4089
|
+
lines.push({ role: "human", text });
|
|
4090
|
+
}
|
|
4091
|
+
} else if (msg.role === "assistant") {
|
|
4092
|
+
const content = msg.content;
|
|
4093
|
+
if (!Array.isArray(content)) {
|
|
4094
|
+
if (typeof content === "string" && content.trim()) {
|
|
4095
|
+
lines.push({ role: "assistant", text: content.trim() });
|
|
4096
|
+
}
|
|
4097
|
+
continue;
|
|
4098
|
+
}
|
|
4099
|
+
for (const block of content) {
|
|
4100
|
+
if (block.type === "text" && block.text) {
|
|
4101
|
+
const trimmed = block.text.trim();
|
|
4102
|
+
if (trimmed)
|
|
4103
|
+
lines.push({ role: "assistant", text: trimmed });
|
|
4104
|
+
} else if (block.type === "toolCall" && block.name) {
|
|
4105
|
+
const desc = summarizeToolInput(block.name, block.arguments);
|
|
4106
|
+
lines.push({ role: "tool", text: desc });
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
return lines;
|
|
4112
|
+
}
|
|
4113
|
+
function summarizeToolInput(name, input) {
|
|
4114
|
+
if (!input)
|
|
4115
|
+
return name;
|
|
4116
|
+
const path = input.path ?? input.file_path ?? input.filePath ?? input.filename;
|
|
4117
|
+
if (typeof path === "string")
|
|
4118
|
+
return `${name} ${path}`;
|
|
4119
|
+
const command = input.command ?? input.cmd;
|
|
4120
|
+
if (typeof command === "string") {
|
|
4121
|
+
const short = command.length > 120 ? command.slice(0, 120) + "..." : command;
|
|
4122
|
+
return `${name} ${short}`;
|
|
4123
|
+
}
|
|
4124
|
+
const query = input.query ?? input.search ?? input.pattern;
|
|
4125
|
+
if (typeof query === "string")
|
|
4126
|
+
return `${name} ${query}`;
|
|
4127
|
+
return name;
|
|
4128
|
+
}
|
|
4129
|
+
var extractors = {
|
|
4130
|
+
"claude-code": extractClaudeCode,
|
|
4131
|
+
pi: extractPi
|
|
4132
|
+
};
|
|
4133
|
+
function getExtractor(agent) {
|
|
4134
|
+
return extractors[agent] ?? null;
|
|
4135
|
+
}
|
|
4136
|
+
|
|
3960
4137
|
// src/commands/sync.ts
|
|
3961
4138
|
import { stat as stat3 } from "fs/promises";
|
|
3962
|
-
var
|
|
4139
|
+
var log6 = createLogger("sync");
|
|
3963
4140
|
var STALE_THRESHOLD_MS = 30 * 60 * 1000;
|
|
3964
4141
|
function requestUploadUrl(opts) {
|
|
3965
4142
|
return ResultAsync.fromPromise(fetch(`${opts.workerUrl}/api/sessions/upload-url`, {
|
|
@@ -4022,7 +4199,7 @@ function buildCommitMeta(opts) {
|
|
|
4022
4199
|
for (const ref of opts.commitRefs) {
|
|
4023
4200
|
const metaResult = await getCommitMeta(ref.sha);
|
|
4024
4201
|
if (metaResult.isErr()) {
|
|
4025
|
-
|
|
4202
|
+
log6.warn(metaResult.error);
|
|
4026
4203
|
continue;
|
|
4027
4204
|
}
|
|
4028
4205
|
commits.push({
|
|
@@ -4050,17 +4227,54 @@ function closeStaleOpenSessions(opts) {
|
|
|
4050
4227
|
const checks = openSessions.map((session) => getFileMtimeMs(session.data_path).map((mtimeMs) => {
|
|
4051
4228
|
if (mtimeMs === null) {
|
|
4052
4229
|
session.status = "ended";
|
|
4053
|
-
|
|
4230
|
+
log6.debug("auto-closed session %s (data file not accessible)", session.id);
|
|
4054
4231
|
} else {
|
|
4055
4232
|
const msSinceModified = now - mtimeMs;
|
|
4056
4233
|
if (msSinceModified > STALE_THRESHOLD_MS) {
|
|
4057
4234
|
session.status = "ended";
|
|
4058
|
-
|
|
4235
|
+
log6.debug("auto-closed stale session %s (data file unchanged for %dm)", session.id, Math.round(msSinceModified / 60000));
|
|
4059
4236
|
}
|
|
4060
4237
|
}
|
|
4061
4238
|
}));
|
|
4062
4239
|
return ResultAsync.combine(checks).map(() => opts.sessions);
|
|
4063
4240
|
}
|
|
4241
|
+
function generateSearchText(opts) {
|
|
4242
|
+
const extractor = getExtractor(opts.session.agent);
|
|
4243
|
+
if (!extractor) {
|
|
4244
|
+
log6.debug("no search text extractor for agent %s, skipping", opts.session.agent);
|
|
4245
|
+
return null;
|
|
4246
|
+
}
|
|
4247
|
+
const searchLines = extractor(opts.rawData);
|
|
4248
|
+
if (searchLines.length === 0)
|
|
4249
|
+
return null;
|
|
4250
|
+
const branches = [
|
|
4251
|
+
...new Set(opts.session.commits.map((c) => c.branch).filter(Boolean))
|
|
4252
|
+
];
|
|
4253
|
+
return buildSearchText({
|
|
4254
|
+
metadata: {
|
|
4255
|
+
sessionId: opts.session.id,
|
|
4256
|
+
agent: opts.session.agent,
|
|
4257
|
+
commits: opts.commits.map((c) => c.sha.slice(0, 7)),
|
|
4258
|
+
branch: branches[0] ?? "",
|
|
4259
|
+
repo: `${opts.org}/${opts.repo}`
|
|
4260
|
+
},
|
|
4261
|
+
lines: searchLines
|
|
4262
|
+
});
|
|
4263
|
+
}
|
|
4264
|
+
function uploadSearchText(opts) {
|
|
4265
|
+
return ResultAsync.fromPromise(fetch(opts.url, {
|
|
4266
|
+
method: "PUT",
|
|
4267
|
+
headers: { "Content-Type": "text/plain" },
|
|
4268
|
+
body: opts.data
|
|
4269
|
+
}).then((response) => {
|
|
4270
|
+
if (!response.ok) {
|
|
4271
|
+
throw new Error(`R2 search upload failed: HTTP ${response.status}`);
|
|
4272
|
+
}
|
|
4273
|
+
}), toCliError({
|
|
4274
|
+
message: "Search text R2 upload failed",
|
|
4275
|
+
code: "NETWORK_ERROR"
|
|
4276
|
+
}));
|
|
4277
|
+
}
|
|
4064
4278
|
function syncSessions(opts) {
|
|
4065
4279
|
return ResultAsync.fromSafePromise((async () => {
|
|
4066
4280
|
const remaining = [];
|
|
@@ -4071,13 +4285,13 @@ function syncSessions(opts) {
|
|
|
4071
4285
|
}
|
|
4072
4286
|
const dataResult = await readSessionData(session.data_path);
|
|
4073
4287
|
if (dataResult.isErr()) {
|
|
4074
|
-
|
|
4288
|
+
log6.warn(dataResult.error);
|
|
4075
4289
|
remaining.push(session);
|
|
4076
4290
|
continue;
|
|
4077
4291
|
}
|
|
4078
4292
|
const data = dataResult.value;
|
|
4079
4293
|
if (data === null) {
|
|
4080
|
-
|
|
4294
|
+
log6.warn(`dropping session ${session.id}: data file missing at ${session.data_path}`);
|
|
4081
4295
|
continue;
|
|
4082
4296
|
}
|
|
4083
4297
|
const commitsResult = await buildCommitMeta({
|
|
@@ -4086,7 +4300,7 @@ function syncSessions(opts) {
|
|
|
4086
4300
|
repo: opts.repo
|
|
4087
4301
|
});
|
|
4088
4302
|
if (commitsResult.isErr()) {
|
|
4089
|
-
|
|
4303
|
+
log6.warn(commitsResult.error);
|
|
4090
4304
|
remaining.push(session);
|
|
4091
4305
|
continue;
|
|
4092
4306
|
}
|
|
@@ -4096,7 +4310,7 @@ function syncSessions(opts) {
|
|
|
4096
4310
|
sessionId: session.id
|
|
4097
4311
|
});
|
|
4098
4312
|
if (uploadUrlResult.isErr()) {
|
|
4099
|
-
|
|
4313
|
+
log6.warn(`failed to get upload URL for session ${session.id}: ${uploadUrlResult.error.message}`);
|
|
4100
4314
|
remaining.push(session);
|
|
4101
4315
|
continue;
|
|
4102
4316
|
}
|
|
@@ -4105,11 +4319,29 @@ function syncSessions(opts) {
|
|
|
4105
4319
|
data
|
|
4106
4320
|
});
|
|
4107
4321
|
if (directUploadResult.isErr()) {
|
|
4108
|
-
|
|
4322
|
+
log6.warn(`R2 upload failed for session ${session.id}: ${directUploadResult.error.message}`);
|
|
4109
4323
|
remaining.push(session);
|
|
4110
4324
|
continue;
|
|
4111
4325
|
}
|
|
4112
|
-
|
|
4326
|
+
log6.debug("uploaded session %s data directly to R2", session.id);
|
|
4327
|
+
const searchText = generateSearchText({
|
|
4328
|
+
session,
|
|
4329
|
+
rawData: data,
|
|
4330
|
+
commits: commitsResult.value,
|
|
4331
|
+
org: opts.org,
|
|
4332
|
+
repo: opts.repo
|
|
4333
|
+
});
|
|
4334
|
+
if (searchText && uploadUrlResult.value.search_url) {
|
|
4335
|
+
const searchUploadResult = await uploadSearchText({
|
|
4336
|
+
url: uploadUrlResult.value.search_url,
|
|
4337
|
+
data: searchText
|
|
4338
|
+
});
|
|
4339
|
+
if (searchUploadResult.isErr()) {
|
|
4340
|
+
log6.warn(`search text upload failed for session ${session.id}: ${searchUploadResult.error.message}`);
|
|
4341
|
+
} else {
|
|
4342
|
+
log6.debug("uploaded search text for session %s", session.id);
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4113
4345
|
const metadataResult = await postSessionMetadata({
|
|
4114
4346
|
workerUrl: opts.workerUrl,
|
|
4115
4347
|
token: opts.token,
|
|
@@ -4122,14 +4354,14 @@ function syncSessions(opts) {
|
|
|
4122
4354
|
commits: commitsResult.value
|
|
4123
4355
|
});
|
|
4124
4356
|
if (metadataResult.isErr()) {
|
|
4125
|
-
|
|
4357
|
+
log6.warn(`metadata upload failed for session ${session.id}: ${metadataResult.error.message}`);
|
|
4126
4358
|
remaining.push(session);
|
|
4127
4359
|
continue;
|
|
4128
4360
|
}
|
|
4129
4361
|
if (session.status === "open") {
|
|
4130
4362
|
remaining.push(session);
|
|
4131
4363
|
}
|
|
4132
|
-
|
|
4364
|
+
log6.debug("synced session %s", session.id);
|
|
4133
4365
|
}
|
|
4134
4366
|
return remaining;
|
|
4135
4367
|
})());
|
|
@@ -4175,8 +4407,157 @@ function sync(opts) {
|
|
|
4175
4407
|
// src/commands/push.ts
|
|
4176
4408
|
var push = sync;
|
|
4177
4409
|
|
|
4410
|
+
// src/commands/search.ts
|
|
4411
|
+
var log7 = createLogger("search");
|
|
4412
|
+
function fetchSearch(opts) {
|
|
4413
|
+
const path = opts.isAi ? "/api/search/ai" : "/api/search";
|
|
4414
|
+
const url = `${opts.workerUrl}${path}?q=${encodeURIComponent(opts.query)}`;
|
|
4415
|
+
return ResultAsync.fromPromise(fetch(url, {
|
|
4416
|
+
headers: { Authorization: `Bearer ${opts.token}` }
|
|
4417
|
+
}).then(async (response) => {
|
|
4418
|
+
if (!response.ok) {
|
|
4419
|
+
const body = await response.text().catch(() => "");
|
|
4420
|
+
throw new Error(`HTTP ${response.status}: ${body}`);
|
|
4421
|
+
}
|
|
4422
|
+
return response.json();
|
|
4423
|
+
}), toCliError({ message: "Search request failed", code: "NETWORK_ERROR" }));
|
|
4424
|
+
}
|
|
4425
|
+
function fetchSessionCommits(opts) {
|
|
4426
|
+
const url = `${opts.workerUrl}/api/sessions/${opts.sessionId}/commits`;
|
|
4427
|
+
return ResultAsync.fromPromise(fetch(url, {
|
|
4428
|
+
headers: { Authorization: `Bearer ${opts.token}` }
|
|
4429
|
+
}).then(async (response) => {
|
|
4430
|
+
if (!response.ok)
|
|
4431
|
+
return [];
|
|
4432
|
+
const data = await response.json();
|
|
4433
|
+
return data.commits;
|
|
4434
|
+
}), toCliError({
|
|
4435
|
+
message: "Failed to fetch session commits",
|
|
4436
|
+
code: "NETWORK_ERROR"
|
|
4437
|
+
})).orElse(() => ok([]));
|
|
4438
|
+
}
|
|
4439
|
+
function extractSessionId(filename) {
|
|
4440
|
+
const match = filename.match(/(?:sessions|search)\/(.+?)\.(?:json|txt)$/);
|
|
4441
|
+
return match ? match[1] : filename;
|
|
4442
|
+
}
|
|
4443
|
+
function truncate(opts) {
|
|
4444
|
+
if (opts.text.length <= opts.maxLength)
|
|
4445
|
+
return opts.text;
|
|
4446
|
+
return opts.text.slice(0, opts.maxLength) + "...";
|
|
4447
|
+
}
|
|
4448
|
+
function formatSnippet(text) {
|
|
4449
|
+
const cleaned = text.replace(/\\n/g, " ").replace(/\\"/g, '"').replace(/\s+/g, " ").trim();
|
|
4450
|
+
return truncate({ text: cleaned, maxLength: 200 });
|
|
4451
|
+
}
|
|
4452
|
+
function buildCommitUrl(opts) {
|
|
4453
|
+
return `${opts.workerUrl}/app/${opts.org}/${opts.repo}/${opts.sha}`;
|
|
4454
|
+
}
|
|
4455
|
+
function renderSearchResults(opts) {
|
|
4456
|
+
if (opts.results.data.length === 0) {
|
|
4457
|
+
log7.info("No results found.");
|
|
4458
|
+
return;
|
|
4459
|
+
}
|
|
4460
|
+
log7.info(`${opts.results.data.length} result(s) for "${opts.results.search_query}"
|
|
4461
|
+
`);
|
|
4462
|
+
for (const item of opts.results.data) {
|
|
4463
|
+
const sessionId = extractSessionId(item.filename);
|
|
4464
|
+
const scorePercent = (item.score * 100).toFixed(1);
|
|
4465
|
+
log7.info(` ${sessionId} [${scorePercent}%]`);
|
|
4466
|
+
const snippet = item.content[0]?.text;
|
|
4467
|
+
if (snippet) {
|
|
4468
|
+
log7.info(` ${formatSnippet(snippet)}`);
|
|
4469
|
+
}
|
|
4470
|
+
const commits = opts.commitMap.get(sessionId) ?? [];
|
|
4471
|
+
if (commits.length > 0) {
|
|
4472
|
+
for (const commit of commits) {
|
|
4473
|
+
const url = buildCommitUrl({
|
|
4474
|
+
workerUrl: opts.workerUrl,
|
|
4475
|
+
org: commit.org,
|
|
4476
|
+
repo: commit.repo,
|
|
4477
|
+
sha: commit.commit_sha
|
|
4478
|
+
});
|
|
4479
|
+
log7.info(` -> ${url}`);
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
log7.info("");
|
|
4483
|
+
}
|
|
4484
|
+
}
|
|
4485
|
+
function renderAiSearchResults(opts) {
|
|
4486
|
+
if (opts.results.response) {
|
|
4487
|
+
log7.info(opts.results.response);
|
|
4488
|
+
log7.info("");
|
|
4489
|
+
}
|
|
4490
|
+
if (opts.results.data.length > 0) {
|
|
4491
|
+
log7.info(`--- Sources (${opts.results.data.length}) ---
|
|
4492
|
+
`);
|
|
4493
|
+
for (const item of opts.results.data) {
|
|
4494
|
+
const sessionId = extractSessionId(item.filename);
|
|
4495
|
+
const scorePercent = (item.score * 100).toFixed(1);
|
|
4496
|
+
log7.info(` ${sessionId} [${scorePercent}%]`);
|
|
4497
|
+
const commits = opts.commitMap.get(sessionId) ?? [];
|
|
4498
|
+
if (commits.length > 0) {
|
|
4499
|
+
for (const commit of commits) {
|
|
4500
|
+
const url = buildCommitUrl({
|
|
4501
|
+
workerUrl: opts.workerUrl,
|
|
4502
|
+
org: commit.org,
|
|
4503
|
+
repo: commit.repo,
|
|
4504
|
+
sha: commit.commit_sha
|
|
4505
|
+
});
|
|
4506
|
+
log7.info(` -> ${url}`);
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
}
|
|
4510
|
+
log7.info("");
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
function isAiSearchResponse(response) {
|
|
4514
|
+
return "response" in response;
|
|
4515
|
+
}
|
|
4516
|
+
function search(opts) {
|
|
4517
|
+
return safeTry(async function* () {
|
|
4518
|
+
const config = yield* resolveConfig();
|
|
4519
|
+
if (!config) {
|
|
4520
|
+
return err(new CliError({
|
|
4521
|
+
message: "Not configured. Run 'residue login' first.",
|
|
4522
|
+
code: "CONFIG_MISSING"
|
|
4523
|
+
}));
|
|
4524
|
+
}
|
|
4525
|
+
const results = yield* fetchSearch({
|
|
4526
|
+
workerUrl: config.worker_url,
|
|
4527
|
+
token: config.token,
|
|
4528
|
+
query: opts.query,
|
|
4529
|
+
isAi: opts.isAi ?? false
|
|
4530
|
+
});
|
|
4531
|
+
const sessionIds = results.data.map((item) => extractSessionId(item.filename));
|
|
4532
|
+
const uniqueSessionIds = [...new Set(sessionIds)];
|
|
4533
|
+
const commitResults = yield* ResultAsync.combine(uniqueSessionIds.map((sessionId) => fetchSessionCommits({
|
|
4534
|
+
workerUrl: config.worker_url,
|
|
4535
|
+
token: config.token,
|
|
4536
|
+
sessionId
|
|
4537
|
+
}).map((commits) => ({ sessionId, commits }))));
|
|
4538
|
+
const commitMap = new Map;
|
|
4539
|
+
for (const entry of commitResults) {
|
|
4540
|
+
commitMap.set(entry.sessionId, entry.commits);
|
|
4541
|
+
}
|
|
4542
|
+
if (opts.isAi && isAiSearchResponse(results)) {
|
|
4543
|
+
renderAiSearchResults({
|
|
4544
|
+
results,
|
|
4545
|
+
commitMap,
|
|
4546
|
+
workerUrl: config.worker_url
|
|
4547
|
+
});
|
|
4548
|
+
} else {
|
|
4549
|
+
renderSearchResults({
|
|
4550
|
+
results,
|
|
4551
|
+
commitMap,
|
|
4552
|
+
workerUrl: config.worker_url
|
|
4553
|
+
});
|
|
4554
|
+
}
|
|
4555
|
+
return ok(undefined);
|
|
4556
|
+
});
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4178
4559
|
// src/commands/session-end.ts
|
|
4179
|
-
var
|
|
4560
|
+
var log8 = createLogger("session");
|
|
4180
4561
|
function sessionEnd(opts) {
|
|
4181
4562
|
return safeTry(async function* () {
|
|
4182
4563
|
const projectRoot = yield* getProjectRoot();
|
|
@@ -4193,13 +4574,13 @@ function sessionEnd(opts) {
|
|
|
4193
4574
|
id: opts.id,
|
|
4194
4575
|
updates: { status: "ended" }
|
|
4195
4576
|
});
|
|
4196
|
-
|
|
4577
|
+
log8.debug("session %s ended", opts.id);
|
|
4197
4578
|
return ok(undefined);
|
|
4198
4579
|
});
|
|
4199
4580
|
}
|
|
4200
4581
|
|
|
4201
4582
|
// src/commands/session-start.ts
|
|
4202
|
-
var
|
|
4583
|
+
var log9 = createLogger("session");
|
|
4203
4584
|
function sessionStart(opts) {
|
|
4204
4585
|
const id = crypto.randomUUID();
|
|
4205
4586
|
return getProjectRoot().andThen(getPendingPath).andThen((pendingPath) => addSession({
|
|
@@ -4214,7 +4595,7 @@ function sessionStart(opts) {
|
|
|
4214
4595
|
}
|
|
4215
4596
|
})).map(() => {
|
|
4216
4597
|
process.stdout.write(id);
|
|
4217
|
-
|
|
4598
|
+
log9.debug("session started for %s", opts.agent);
|
|
4218
4599
|
});
|
|
4219
4600
|
}
|
|
4220
4601
|
|
|
@@ -4363,7 +4744,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
4363
4744
|
`;
|
|
4364
4745
|
|
|
4365
4746
|
// src/commands/setup.ts
|
|
4366
|
-
var
|
|
4747
|
+
var log10 = createLogger("setup");
|
|
4367
4748
|
var CLAUDE_HOOK_COMMAND = "residue hook claude-code";
|
|
4368
4749
|
function hasResidueHook(entries) {
|
|
4369
4750
|
return entries.some((entry) => entry.hooks.some((h) => h.command === CLAUDE_HOOK_COMMAND));
|
|
@@ -4408,12 +4789,12 @@ function setupClaudeCode(projectRoot) {
|
|
|
4408
4789
|
isChanged = true;
|
|
4409
4790
|
}
|
|
4410
4791
|
if (!isChanged) {
|
|
4411
|
-
|
|
4792
|
+
log10.info("residue hooks already configured in .claude/settings.json");
|
|
4412
4793
|
return;
|
|
4413
4794
|
}
|
|
4414
4795
|
await writeFile3(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
4415
4796
|
`);
|
|
4416
|
-
|
|
4797
|
+
log10.info("Configured Claude Code hooks in .claude/settings.json");
|
|
4417
4798
|
})(), toCliError({ message: "Failed to setup Claude Code", code: "IO_ERROR" }));
|
|
4418
4799
|
}
|
|
4419
4800
|
function setupPi(projectRoot) {
|
|
@@ -4427,11 +4808,11 @@ function setupPi(projectRoot) {
|
|
|
4427
4808
|
isExisting = true;
|
|
4428
4809
|
} catch {}
|
|
4429
4810
|
if (isExisting) {
|
|
4430
|
-
|
|
4811
|
+
log10.info("residue extension already exists at .pi/extensions/residue.ts");
|
|
4431
4812
|
return;
|
|
4432
4813
|
}
|
|
4433
4814
|
await writeFile3(targetPath, extension_ts_default);
|
|
4434
|
-
|
|
4815
|
+
log10.info("Installed pi extension at .pi/extensions/residue.ts");
|
|
4435
4816
|
})(), toCliError({ message: "Failed to setup pi", code: "IO_ERROR" }));
|
|
4436
4817
|
}
|
|
4437
4818
|
function setup(opts) {
|
|
@@ -4450,9 +4831,144 @@ function setup(opts) {
|
|
|
4450
4831
|
});
|
|
4451
4832
|
}
|
|
4452
4833
|
|
|
4834
|
+
// src/commands/status.ts
|
|
4835
|
+
import { readFile as readFile4, stat as stat5 } from "fs/promises";
|
|
4836
|
+
import { join as join6 } from "path";
|
|
4837
|
+
var log11 = createLogger("status");
|
|
4838
|
+
function checkFileExists(path) {
|
|
4839
|
+
return ResultAsync.fromPromise(stat5(path).then(() => true), toCliError({ message: "Failed to check file", code: "IO_ERROR" })).orElse(() => okAsync(false));
|
|
4840
|
+
}
|
|
4841
|
+
function checkHookInstalled(opts) {
|
|
4842
|
+
const hookPath = join6(opts.gitDir, "hooks", opts.hookName);
|
|
4843
|
+
return ResultAsync.fromPromise(readFile4(hookPath, "utf-8").then((content) => content.includes(opts.needle)), toCliError({ message: "Failed to read hook", code: "IO_ERROR" })).orElse(() => okAsync(false));
|
|
4844
|
+
}
|
|
4845
|
+
function getGitDir() {
|
|
4846
|
+
return ResultAsync.fromPromise((async () => {
|
|
4847
|
+
const proc = Bun.spawn(["git", "rev-parse", "--git-dir"], {
|
|
4848
|
+
stdout: "pipe",
|
|
4849
|
+
stderr: "pipe"
|
|
4850
|
+
});
|
|
4851
|
+
await proc.exited;
|
|
4852
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
4853
|
+
})(), toCliError({ message: "Failed to get git directory", code: "GIT_ERROR" }));
|
|
4854
|
+
}
|
|
4855
|
+
function status() {
|
|
4856
|
+
return safeTry(async function* () {
|
|
4857
|
+
const isRepo = yield* isGitRepo();
|
|
4858
|
+
if (!isRepo) {
|
|
4859
|
+
log11.info("Not a git repository.");
|
|
4860
|
+
return ok(undefined);
|
|
4861
|
+
}
|
|
4862
|
+
const projectRoot = yield* getProjectRoot();
|
|
4863
|
+
log11.info("Login");
|
|
4864
|
+
const globalConfig = yield* readConfig();
|
|
4865
|
+
if (globalConfig) {
|
|
4866
|
+
log11.info(` global: ${globalConfig.worker_url}`);
|
|
4867
|
+
} else {
|
|
4868
|
+
log11.info(" global: not configured");
|
|
4869
|
+
}
|
|
4870
|
+
const localConfig = yield* readLocalConfig(projectRoot);
|
|
4871
|
+
if (localConfig) {
|
|
4872
|
+
log11.info(` local: ${localConfig.worker_url}`);
|
|
4873
|
+
} else {
|
|
4874
|
+
log11.info(" local: not configured");
|
|
4875
|
+
}
|
|
4876
|
+
const isActiveConfig = localConfig ?? globalConfig;
|
|
4877
|
+
if (isActiveConfig) {
|
|
4878
|
+
log11.info(` active: ${isActiveConfig.worker_url}`);
|
|
4879
|
+
} else {
|
|
4880
|
+
log11.info(' active: none (run "residue login" to configure)');
|
|
4881
|
+
}
|
|
4882
|
+
log11.info("");
|
|
4883
|
+
log11.info("Hooks");
|
|
4884
|
+
const gitDir = yield* getGitDir();
|
|
4885
|
+
const isPostCommitInstalled = yield* checkHookInstalled({
|
|
4886
|
+
gitDir,
|
|
4887
|
+
hookName: "post-commit",
|
|
4888
|
+
needle: "residue capture"
|
|
4889
|
+
});
|
|
4890
|
+
log11.info(` post-commit: ${isPostCommitInstalled ? "installed" : "not installed"}`);
|
|
4891
|
+
const isPrePushInstalled = yield* checkHookInstalled({
|
|
4892
|
+
gitDir,
|
|
4893
|
+
hookName: "pre-push",
|
|
4894
|
+
needle: "residue sync"
|
|
4895
|
+
});
|
|
4896
|
+
log11.info(` pre-push: ${isPrePushInstalled ? "installed" : "not installed"}`);
|
|
4897
|
+
if (!isPostCommitInstalled || !isPrePushInstalled) {
|
|
4898
|
+
log11.info(' run "residue init" to install missing hooks');
|
|
4899
|
+
}
|
|
4900
|
+
log11.info("");
|
|
4901
|
+
log11.info("Adapters");
|
|
4902
|
+
const isClaudeSetup = yield* checkFileExists(join6(projectRoot, ".claude", "settings.json"));
|
|
4903
|
+
let isClaudeHookConfigured = false;
|
|
4904
|
+
if (isClaudeSetup) {
|
|
4905
|
+
isClaudeHookConfigured = yield* ResultAsync.fromPromise(readFile4(join6(projectRoot, ".claude", "settings.json"), "utf-8").then((content) => content.includes("residue hook claude-code")), toCliError({
|
|
4906
|
+
message: "Failed to read claude settings",
|
|
4907
|
+
code: "IO_ERROR"
|
|
4908
|
+
})).orElse(() => okAsync(false));
|
|
4909
|
+
}
|
|
4910
|
+
log11.info(` claude-code: ${isClaudeHookConfigured ? "configured" : "not configured"}`);
|
|
4911
|
+
const isPiSetup = yield* checkFileExists(join6(projectRoot, ".pi", "extensions", "residue.ts"));
|
|
4912
|
+
log11.info(` pi: ${isPiSetup ? "configured" : "not configured"}`);
|
|
4913
|
+
log11.info("");
|
|
4914
|
+
log11.info("Sessions");
|
|
4915
|
+
const pendingPath = yield* getPendingPath(projectRoot);
|
|
4916
|
+
const sessions = yield* readPending(pendingPath);
|
|
4917
|
+
if (sessions.length === 0) {
|
|
4918
|
+
log11.info(" no pending sessions");
|
|
4919
|
+
} else {
|
|
4920
|
+
const openSessions = sessions.filter((s) => s.status === "open");
|
|
4921
|
+
const endedSessions = sessions.filter((s) => s.status === "ended");
|
|
4922
|
+
const totalCommits = sessions.reduce((sum, s) => sum + s.commits.length, 0);
|
|
4923
|
+
const sessionsWithCommits = sessions.filter((s) => s.commits.length > 0);
|
|
4924
|
+
log11.info(` total: ${sessions.length}`);
|
|
4925
|
+
log11.info(` open: ${openSessions.length}`);
|
|
4926
|
+
log11.info(` ended: ${endedSessions.length}`);
|
|
4927
|
+
log11.info(` commits: ${totalCommits} across ${sessionsWithCommits.length} session(s)`);
|
|
4928
|
+
const isReadyToSync = sessionsWithCommits.length > 0;
|
|
4929
|
+
if (isReadyToSync) {
|
|
4930
|
+
log11.info(` ${sessionsWithCommits.length} session(s) ready to sync on next push`);
|
|
4931
|
+
} else {
|
|
4932
|
+
log11.info(" no sessions ready to sync (no commits captured yet)");
|
|
4933
|
+
}
|
|
4934
|
+
}
|
|
4935
|
+
return ok(undefined);
|
|
4936
|
+
});
|
|
4937
|
+
}
|
|
4938
|
+
// package.json
|
|
4939
|
+
var package_default = {
|
|
4940
|
+
name: "@residue/cli",
|
|
4941
|
+
version: "0.0.4",
|
|
4942
|
+
repository: {
|
|
4943
|
+
type: "git",
|
|
4944
|
+
url: "https://github.com/butttons/residue",
|
|
4945
|
+
directory: "packages/cli"
|
|
4946
|
+
},
|
|
4947
|
+
type: "module",
|
|
4948
|
+
bin: {
|
|
4949
|
+
residue: "./dist/index.js"
|
|
4950
|
+
},
|
|
4951
|
+
scripts: {
|
|
4952
|
+
dev: "bun run src/index.ts",
|
|
4953
|
+
build: "bun build src/index.ts --outfile dist/index.js --target bun",
|
|
4954
|
+
test: "bun test",
|
|
4955
|
+
typecheck: "tsc --noEmit"
|
|
4956
|
+
},
|
|
4957
|
+
devDependencies: {
|
|
4958
|
+
"@types/bun": "latest",
|
|
4959
|
+
"@types/debug": "^4.1.12",
|
|
4960
|
+
typescript: "^5.7.0"
|
|
4961
|
+
},
|
|
4962
|
+
dependencies: {
|
|
4963
|
+
commander: "^14.0.3",
|
|
4964
|
+
debug: "^4.4.3",
|
|
4965
|
+
neverthrow: "^8.2.0"
|
|
4966
|
+
}
|
|
4967
|
+
};
|
|
4968
|
+
|
|
4453
4969
|
// src/index.ts
|
|
4454
4970
|
var program2 = new Command;
|
|
4455
|
-
program2.name("residue").description("Capture AI agent conversations linked to git commits").version(
|
|
4971
|
+
program2.name("residue").description("Capture AI agent conversations linked to git commits").version(package_default.version);
|
|
4456
4972
|
program2.command("login").description("Save worker URL and auth token").requiredOption("--url <worker_url>", "Worker URL").requiredOption("--token <auth_token>", "Auth token").option("--local", "Save config to this project instead of globally").action(wrapCommand((opts) => login({ url: opts.url, token: opts.token, isLocal: opts.local })));
|
|
4457
4973
|
program2.command("init").description("Install git hooks in current repo").action(wrapCommand(() => init()));
|
|
4458
4974
|
program2.command("setup").description("Configure an agent adapter for this project").argument("<agent>", "Agent to set up (claude-code, pi)").action(wrapCommand((agent) => setup({ agent })));
|
|
@@ -4468,4 +4984,7 @@ session.command("end").description("Mark an agent session as ended").requiredOpt
|
|
|
4468
4984
|
program2.command("capture").description("Tag pending sessions with current commit SHA (called by post-commit hook)").action(wrapHookCommand(() => capture()));
|
|
4469
4985
|
program2.command("sync").description("Upload pending sessions to worker (called by pre-push hook)").option("--remote-url <url>", "Remote URL (passed by pre-push hook)").action(wrapHookCommand((opts) => sync({ remoteUrl: opts.remoteUrl })));
|
|
4470
4986
|
program2.command("push").description("Upload pending sessions to worker (manual trigger)").action(wrapCommand(() => push()));
|
|
4987
|
+
program2.command("clear").description("Remove pending sessions from the local queue").option("--id <session-id>", "Clear a specific session by ID").action(wrapCommand((opts) => clear({ id: opts.id })));
|
|
4988
|
+
program2.command("status").description("Show current residue state for this project").action(wrapCommand(() => status()));
|
|
4989
|
+
program2.command("search").description("Search session history").argument("<query>", "Search query").option("--ai", "Use AI-powered search (generates an answer with citations)").action(wrapCommand((query, opts) => search({ query, isAi: opts.ai })));
|
|
4471
4990
|
program2.parse();
|