querysub 0.442.0 → 0.446.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/.claude/settings.local.json +10 -1
- package/package.json +4 -2
- package/src/-a-archives/archivesBackBlaze.ts +9 -3
- package/src/0-path-value-core/PathRouter.ts +2 -2
- package/src/0-path-value-core/pathValueArchives.ts +1 -1
- package/src/0-path-value-core/pathValueCore.ts +5 -1
- package/src/2-proxy/PathValueProxyWatcher.ts +0 -3
- package/src/2-proxy/TransactionDelayer.ts +1 -1
- package/src/3-path-functions/PathFunctionRunner.ts +1 -1
- package/src/4-deploy/git.ts +56 -1
- package/src/4-querysub/QuerysubController.ts +2 -0
- package/src/4-querysub/querysubPrediction.ts +5 -2
- package/src/deployManager/components/CommitModal.tsx +274 -0
- package/src/deployManager/components/deployButtons.tsx +14 -54
- package/src/deployManager/machineApplyMainCode.ts +11 -6
- package/src/deployManager/machineSchema.ts +17 -1
- package/src/diagnostics/debugger/debugger-remote.ts +231 -0
- package/src/diagnostics/debugger/mcp-server.ts +775 -0
- package/src/diagnostics/logs/errorNotifications2/openRouterHelper.ts +127 -0
- package/src/diagnostics/pathAuditer.ts +5 -0
- package/test.ts +12 -3
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
4
|
"Bash(Get-ChildItem *)",
|
|
5
|
-
"mcp__querysub-logs__searchIndexes"
|
|
5
|
+
"mcp__querysub-logs__searchIndexes",
|
|
6
|
+
"mcp__node-debugger__listNodes",
|
|
7
|
+
"mcp__node-debugger__attach",
|
|
8
|
+
"mcp__node-debugger__evaluate",
|
|
9
|
+
"mcp__node-debugger__attachNode",
|
|
10
|
+
"mcp__node-debugger__getSource",
|
|
11
|
+
"mcp__node-debugger__logpoint",
|
|
12
|
+
"Bash(claude mcp *)",
|
|
13
|
+
"mcp__querysub-logs__search",
|
|
14
|
+
"Bash(yarn type *)"
|
|
6
15
|
]
|
|
7
16
|
}
|
|
8
17
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.446.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"audit-imports": "yarn typenode ./src/diagnostics/auditImportViolations.ts",
|
|
22
22
|
"test": "yarn typenode ./test.ts",
|
|
23
23
|
"mcp": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts",
|
|
24
|
-
"mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/"
|
|
24
|
+
"mc": "yarn typenode ./src/diagnostics/logs/IndexedLogs/MCPIndexedLogsEntry.ts --cwd D:/repos/qs-cyoa/",
|
|
25
|
+
"mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
|
|
26
|
+
"mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/"
|
|
25
27
|
},
|
|
26
28
|
"bin": {
|
|
27
29
|
"deploy": "./bin/deploy.js",
|
|
@@ -396,7 +396,11 @@ export class ArchivesBackblaze {
|
|
|
396
396
|
public?: boolean;
|
|
397
397
|
immutable?: boolean;
|
|
398
398
|
cacheTime?: number;
|
|
399
|
-
|
|
399
|
+
allowedOrigins?: string[];
|
|
400
|
+
}) {
|
|
401
|
+
// Get the api, to setup cors
|
|
402
|
+
void this.getBucketAPI();
|
|
403
|
+
}
|
|
400
404
|
|
|
401
405
|
private bucketName = this.config.bucketName.replaceAll(/[^\w\d]/g, "-");
|
|
402
406
|
private bucketId = "";
|
|
@@ -425,7 +429,7 @@ export class ArchivesBackblaze {
|
|
|
425
429
|
// ALWAYS set access control, as we can make urls for private buckets with getDownloadAuthorization
|
|
426
430
|
let desiredCorsRules = [{
|
|
427
431
|
corsRuleName: "allowAll",
|
|
428
|
-
allowedOrigins: ["https"],
|
|
432
|
+
allowedOrigins: this.config.allowedOrigins ?? ["https"],
|
|
429
433
|
allowedOperations: ["b2_download_file_by_id", "b2_download_file_by_name"],
|
|
430
434
|
allowedHeaders: ["range"],
|
|
431
435
|
exposeHeaders: ["x-bz-content-sha1"],
|
|
@@ -1024,7 +1028,8 @@ export const getArchivesBackblazePublicImmutable = cache((domain: string) => {
|
|
|
1024
1028
|
return new ArchivesBackblaze({
|
|
1025
1029
|
bucketName: domain + "-public-immutable",
|
|
1026
1030
|
public: true,
|
|
1027
|
-
immutable: true
|
|
1031
|
+
immutable: true,
|
|
1032
|
+
allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
|
|
1028
1033
|
});
|
|
1029
1034
|
});
|
|
1030
1035
|
|
|
@@ -1036,5 +1041,6 @@ export const getArchivesBackblazePublic = cache((domain: string) => {
|
|
|
1036
1041
|
bucketName: domain + "-public",
|
|
1037
1042
|
public: true,
|
|
1038
1043
|
cacheTime: timeInMinute,
|
|
1044
|
+
allowedOrigins: [`https://${domain}`, `https://127-0-0-1.${domain}:7007`],
|
|
1039
1045
|
});
|
|
1040
1046
|
});
|
|
@@ -36,10 +36,11 @@ export type AuthoritySpec = {
|
|
|
36
36
|
// If the path.startsWith(prefix), but prefix !== path, then we hash getPathIndex(path, hashIndex)
|
|
37
37
|
// - For now, let's just never add overlapping prefixes.
|
|
38
38
|
prefixes: PrefixMatcher[];
|
|
39
|
+
// - Make sure to set this if you just want the prefix values, otherwise you will get all that that prefixes don't match (the prefixes exclude prefix match but where route does not match).
|
|
39
40
|
excludeDefault?: boolean;
|
|
40
41
|
};
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
// If you want to match just a simple path, use parsePrefixMatcher. It won't match ===, so get the parent if you want that (and then filter the result).
|
|
43
44
|
export type PrefixMatcher = {
|
|
44
45
|
prefix: string;
|
|
45
46
|
additionalMatches: {
|
|
@@ -59,7 +60,6 @@ function matchesPrefix(matcher: PrefixMatcher, path: string): boolean {
|
|
|
59
60
|
return false;
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
|
-
if (getPathIndex(path, matcher.childKeyIndex) === undefined) return false;
|
|
63
63
|
return true;
|
|
64
64
|
}
|
|
65
65
|
// Checks if they're asking for the exact prefix, which would mean that the children would be the direct children of this prefix matcher.
|
|
@@ -451,7 +451,7 @@ export class PathValueArchives {
|
|
|
451
451
|
|
|
452
452
|
private static valuePathCountCache: { time: number; count: Promise<number> } | undefined;
|
|
453
453
|
public static async getValuePathCount(): Promise<number> {
|
|
454
|
-
const ONE_HOUR =
|
|
454
|
+
const ONE_HOUR = 15 * 60 * 1000;
|
|
455
455
|
let cache = PathValueArchives.valuePathCountCache;
|
|
456
456
|
if (cache && Date.now() - cache.time < ONE_HOUR) {
|
|
457
457
|
return cache.count;
|
|
@@ -4,7 +4,7 @@ import { addEpsilons, minusEpsilon } from "../bits";
|
|
|
4
4
|
import { logErrors } from "../errors";
|
|
5
5
|
import { appendToPathStr, getParentPathStr, getPathDepth, getPathIndexAssert, hack_getPackedPathSuffix, hack_setPackedPathSuffix, hack_stripPackedPath } from "../path";
|
|
6
6
|
import { measureFnc, measureWrap } from "socket-function/src/profiling/measure";
|
|
7
|
-
import { IdentityController_getOwnPubKeyShort } from "../-c-identity/IdentityController";
|
|
7
|
+
import { IdentityController_getOwnPubKeyShort, debugNodeId, debugNodeThread } from "../-c-identity/IdentityController";
|
|
8
8
|
import { pathValueArchives } from "./pathValueArchives";
|
|
9
9
|
import { blue } from "socket-function/src/formatting/logColors";
|
|
10
10
|
import { delay, runInfinitePoll } from "socket-function/src/batching";
|
|
@@ -694,6 +694,10 @@ class AuthorityPathValueStorage {
|
|
|
694
694
|
|
|
695
695
|
public addParentSyncs(parentSyncs: { parentPath: string; sourceNodeId: string }[]) {
|
|
696
696
|
for (let obj of parentSyncs) {
|
|
697
|
+
if (isDebugLogEnabled()) {
|
|
698
|
+
auditLog("RECEIVED PARENT PATH", { parentPath: obj.parentPath, remoteNodeId: debugNodeId(obj.sourceNodeId), remoteNodeThreadId: debugNodeThread(obj.sourceNodeId) });
|
|
699
|
+
}
|
|
700
|
+
|
|
697
701
|
let decoded = decodeParentFilter(obj.parentPath);
|
|
698
702
|
let range = decoded ? { start: decoded.start, end: decoded.end } : { start: 0, end: 1 };
|
|
699
703
|
let parentPath = hack_stripPackedPath(obj.parentPath);
|
|
@@ -1492,12 +1492,9 @@ export class PathValueProxyWatcher {
|
|
|
1492
1492
|
}
|
|
1493
1493
|
watcher.countSinceLastFullSync++;
|
|
1494
1494
|
if (watcher.countSinceLastFullSync > 10) {
|
|
1495
|
-
require("debugbreak")(2);
|
|
1496
|
-
debugger;
|
|
1497
1495
|
console.warn(`Watcher ${watcher.debugName} has been unsynced for ${watcher.countSinceLastFullSync} times. This is fine, but maybe optimize it. Why is it cascading?`, { lastUnsyncedAccesses: watcher.lastUnsyncedAccesses, lastUnsyncedParentAccesses: watcher.lastUnsyncedParentAccesses }, watcher.options.watchFunction);
|
|
1498
1496
|
}
|
|
1499
1497
|
if (watcher.countSinceLastFullSync > 500) {
|
|
1500
|
-
debugger;
|
|
1501
1498
|
// NOTE: Using forceEqualWrites will also fix this a lot of the time, such as when
|
|
1502
1499
|
// a write contains random numbers or dates.
|
|
1503
1500
|
let errorMessage = `Too many attempts (${watcher.countSinceLastFullSync}) to sync with different values. If you are reading in a loop, make sure to read all the values, instead of aborting the loop if a value is not synced. ALSO, make sure you don't access paths with Math.random() or Date.now(). This will prevent the sync loop from ever stabilizing.`;
|
|
@@ -88,5 +88,5 @@ export function waitIfReceivedIncompleteTransaction(watcher: SyncWatcher) {
|
|
|
88
88
|
// This really nicely both blocks it from finishing because of the waiting for the promise and also triggers it automatically when the delay finishes. HOWEVER, In practice, the promise should never be required to trigger it. It should trigger when we receive the missing parts of the transaction, which we will be, of course, watching as they're in the path values that we're accessing. The timeout is just in case something goes wrong and we incorrectly think we should receive the values, but we won't. That way, eventually, we do commit the value.
|
|
89
89
|
// ALSO! Plus the hash I think only stores like 48 bits. So the chance of collision is somewhat high, especially if we're accessing thousands of paths. So sometimes this will trigger even though we're not missing any part just because we had a collision between the path hashes.
|
|
90
90
|
proxyWatcher.triggerOnPromiseFinish(promise, { waitReason: "Missing transaction part" });
|
|
91
|
-
console.
|
|
91
|
+
console.warn(`(NOT an error, convert this to a warning after we finish testing). Waiting for missing transaction part ${newestTime.path} which was written at time ${newestTime.time} (now is ${now}). We have parts of this transaction, but we are missing this specific path.`);
|
|
92
92
|
}
|
|
@@ -453,7 +453,7 @@ export class PathFunctionRunner {
|
|
|
453
453
|
let fraction = getRoutingOverridePart(callPath.CallId)?.route;
|
|
454
454
|
if (fraction === undefined) {
|
|
455
455
|
fraction = PathRouter.getSingleKeyRoute(callPath.CallId);
|
|
456
|
-
console.error(`No routing override found for callId, falling back to direct hash`, { callId: callPath.CallId, fraction });
|
|
456
|
+
console.error(`No routing override found for callId, falling back to direct hash`, { callId: callPath.CallId, fraction, domainName: callPath.DomainName, moduleId: callPath.ModuleId, functionId: callPath.FunctionId, callerMachineId: callPath.callerMachineId, callerIP: callPath.callerIP });
|
|
457
457
|
}
|
|
458
458
|
// It isn't secondary if it is primary
|
|
459
459
|
if (shardRange.startFraction <= fraction && fraction < shardRange.endFraction) return 0;
|
package/src/4-deploy/git.ts
CHANGED
|
@@ -16,6 +16,61 @@ export async function getGitUncommitted(gitDir = "."): Promise<string[]> {
|
|
|
16
16
|
return (await runPromise(`git status --porcelain`, { cwd: gitDir })).split("\n").map(x => x.trim()).filter(x => x);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Cap the diff we hand to the client / AI, so a huge accidental change doesn't
|
|
20
|
+
// blow up the request (or the AI's context window).
|
|
21
|
+
const MAX_DIFF_CHARS = 200_000;
|
|
22
|
+
// Per-file cap for untracked files, so a single large new file doesn't dominate.
|
|
23
|
+
const MAX_UNTRACKED_FILE_CHARS = 20_000;
|
|
24
|
+
|
|
25
|
+
/** Returns a unified-diff-style summary of all uncommitted changes (tracked
|
|
26
|
+
* modifications plus the contents of untracked files), suitable for feeding
|
|
27
|
+
* to an AI for summarization. Output is capped at MAX_DIFF_CHARS. */
|
|
28
|
+
export async function getGitDiff(gitDir = "."): Promise<string> {
|
|
29
|
+
let sections: string[] = [];
|
|
30
|
+
|
|
31
|
+
// Tracked modifications (staged + unstaged) relative to the last commit.
|
|
32
|
+
let trackedDiff = await runPromise(`git diff HEAD`, { cwd: gitDir, quiet: true });
|
|
33
|
+
if (trackedDiff.trim()) {
|
|
34
|
+
sections.push(trackedDiff);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Untracked files don't show up in `git diff HEAD`, so include their
|
|
38
|
+
// contents as all-addition hunks.
|
|
39
|
+
let untrackedFiles = (await runPromise(`git ls-files --others --exclude-standard`, { cwd: gitDir, quiet: true }))
|
|
40
|
+
.split("\n").map(x => x.trim()).filter(x => x);
|
|
41
|
+
for (let file of untrackedFiles) {
|
|
42
|
+
let content: string;
|
|
43
|
+
try {
|
|
44
|
+
content = await fs.promises.readFile(path.join(gitDir, file), "utf8");
|
|
45
|
+
} catch (e: any) {
|
|
46
|
+
sections.push(`--- /dev/null\n+++ b/${file}\n(could not read file: ${e.stack ?? e})`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
let isBinary = false;
|
|
50
|
+
for (let i = 0; i < content.length; i++) {
|
|
51
|
+
if (content.charCodeAt(i) === 0) {
|
|
52
|
+
isBinary = true;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (isBinary) {
|
|
57
|
+
sections.push(`--- /dev/null\n+++ b/${file}\n(binary file, ${content.length} bytes)`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (content.length > MAX_UNTRACKED_FILE_CHARS) {
|
|
61
|
+
content = content.slice(0, MAX_UNTRACKED_FILE_CHARS) + `\n(... file truncated)`;
|
|
62
|
+
}
|
|
63
|
+
let addedLines = content.split("\n").map(line => "+" + line).join("\n");
|
|
64
|
+
sections.push(`--- /dev/null\n+++ b/${file} (new file)\n${addedLines}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let diff = sections.join("\n\n");
|
|
68
|
+
if (diff.length > MAX_DIFF_CHARS) {
|
|
69
|
+
diff = diff.slice(0, MAX_DIFF_CHARS) + `\n\n(... diff truncated, ${diff.length - MAX_DIFF_CHARS} more characters)`;
|
|
70
|
+
}
|
|
71
|
+
return diff;
|
|
72
|
+
}
|
|
73
|
+
|
|
19
74
|
export async function getLatestRefOnUpstreamBranch(gitDir = ".") {
|
|
20
75
|
await runPromise(`git fetch`, { cwd: gitDir });
|
|
21
76
|
return (await runPromise(`git rev-parse @{upstream}`, { cwd: gitDir })).trim();
|
|
@@ -29,7 +84,7 @@ export async function commitAndPush(config: {
|
|
|
29
84
|
const tempFile = path.join(os.tmpdir(), `commit-message-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.txt`);
|
|
30
85
|
|
|
31
86
|
try {
|
|
32
|
-
await fs.promises.writeFile(tempFile, config.message,
|
|
87
|
+
await fs.promises.writeFile(tempFile, config.message, "utf8");
|
|
33
88
|
await runPromise(`git add --all`, { cwd: config.gitDir });
|
|
34
89
|
await runPromise(`git commit -F "${tempFile}"`, { cwd: config.gitDir });
|
|
35
90
|
await runPromise(`git push`, { cwd: config.gitDir });
|
|
@@ -237,6 +237,8 @@ export function anyPredictionsPending() {
|
|
|
237
237
|
}
|
|
238
238
|
export const debug_anyPredictionsPending = anyPredictionsPending;
|
|
239
239
|
export async function waitUntilAllPredictionsFinish() {
|
|
240
|
+
// HACK: Commit an empty function to allow predictions to queue. Now great, and it only works if the predictions can run right away, but... fixes a lot of bugs, most of the time.
|
|
241
|
+
await Querysub.commitAsync(() => { });
|
|
240
242
|
while (anyPredictionsPending()) {
|
|
241
243
|
try {
|
|
242
244
|
await Promise.allSettled(Array.from(pendingPredictedCalls.values()).map(obj => obj.obj.promise));
|
|
@@ -131,7 +131,7 @@ function predictCallBase(config: {
|
|
|
131
131
|
let result = pathValueSerializer.getPathValue(resultObj) as FunctionResult | undefined;
|
|
132
132
|
if (!result) return;
|
|
133
133
|
if (result.lastInternalLoopCount === -1) return;
|
|
134
|
-
cleanupPrediction();
|
|
134
|
+
void cleanupPrediction();
|
|
135
135
|
actualValueFinished.resolve();
|
|
136
136
|
for (let callback of onPredictionFinishedCallbacks) {
|
|
137
137
|
callback({ callId: call.CallId, result, functionId: call.FunctionId });
|
|
@@ -328,7 +328,7 @@ function predictCallBase(config: {
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
setTimeout(cleanupPrediction, Querysub.PREDICTION_MAX_LIFESPAN);
|
|
331
|
-
function cleanupPrediction() {
|
|
331
|
+
async function cleanupPrediction() {
|
|
332
332
|
if (didCancel) return;
|
|
333
333
|
|
|
334
334
|
// ALWAYS reject the prediction, in case the function runner server is down.
|
|
@@ -338,7 +338,10 @@ function predictCallBase(config: {
|
|
|
338
338
|
// which create a memory leak.
|
|
339
339
|
rejectPrediction();
|
|
340
340
|
|
|
341
|
+
|
|
341
342
|
if (Querysub.AUDIT_PREDICTIONS && predictions) {
|
|
343
|
+
// Wait to receive all parts of the prediction
|
|
344
|
+
await delay(10_000);
|
|
342
345
|
const afterTime = { time: call.runAtTime.time, version: Number.MAX_SAFE_INTEGER, creatorId: 0 };
|
|
343
346
|
// Clone predictions, to strip symbols
|
|
344
347
|
let predictionsCopy = cborEncoder().decode(cborEncoder().encode(predictions)) as typeof predictions;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import preact from "preact";
|
|
2
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
3
|
+
import { qreact } from "../../4-dom/qreact";
|
|
4
|
+
import { css } from "../../4-dom/css";
|
|
5
|
+
import { MachineServiceController } from "../machineSchema";
|
|
6
|
+
import { Querysub } from "../../4-querysub/QuerysubController";
|
|
7
|
+
import { InputLabel } from "../../library-components/InputLabel";
|
|
8
|
+
import { DropdownSelector } from "../../library-components/DropdownSelector";
|
|
9
|
+
import { closeAllModals } from "../../5-diagnostics/Modal";
|
|
10
|
+
import { callOpenRouterModel, OpenRouterEffort } from "../../diagnostics/logs/errorNotifications2/openRouterHelper";
|
|
11
|
+
import { formatNumber } from "socket-function/src/formatting/format";
|
|
12
|
+
import { t } from "../../2-proxy/schema2";
|
|
13
|
+
|
|
14
|
+
module.hotreload = true;
|
|
15
|
+
|
|
16
|
+
// Models offered for AI commit-message summarization. These are exact OpenRouter
|
|
17
|
+
// model ids and are passed through to the API verbatim. The first is the default.
|
|
18
|
+
export const SUGGEST_MODELS = [
|
|
19
|
+
"anthropic/claude-sonnet-4.6",
|
|
20
|
+
"google/gemini-3.1-flash-lite",
|
|
21
|
+
"qwen/qwen3.5-9b",
|
|
22
|
+
"~google/gemini-flash-latest",
|
|
23
|
+
"x-ai/grok-4.1-fast",
|
|
24
|
+
"nousresearch/hermes-3-llama-3.1-405b",
|
|
25
|
+
"google/gemini-3.1-pro-preview",
|
|
26
|
+
"anthropic/claude-opus-4.7",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const EFFORT_LEVELS: OpenRouterEffort[] = ["none", "low", "medium", "high"];
|
|
30
|
+
|
|
31
|
+
// localStorage keys for persisting the user's OpenRouter preferences.
|
|
32
|
+
const STORAGE_API_KEY = "commitSummary.openRouterApiKey";
|
|
33
|
+
const STORAGE_MODEL = "commitSummary.model";
|
|
34
|
+
const STORAGE_EFFORT = "commitSummary.effort";
|
|
35
|
+
|
|
36
|
+
const DEFAULT_EFFORT: OpenRouterEffort = "low";
|
|
37
|
+
|
|
38
|
+
const COMMIT_PROMPT = `You are an expert software engineer writing a git commit message.
|
|
39
|
+
Summarize the following code changes into a clear, concise commit message.
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
- The first line is a short summary in the imperative mood, max 72 characters.
|
|
43
|
+
- If useful, add bullet points describing notable changes.
|
|
44
|
+
- Output ONLY the raw commit message. No markdown code fences, no preamble, no quotes.
|
|
45
|
+
|
|
46
|
+
Changes (git diff):
|
|
47
|
+
{{DIFF}}`;
|
|
48
|
+
|
|
49
|
+
const DEFAULT_MODEL = SUGGEST_MODELS[0];
|
|
50
|
+
|
|
51
|
+
function loadStored(key: string, fallback: string): string {
|
|
52
|
+
if (typeof localStorage === "undefined") return fallback;
|
|
53
|
+
return localStorage.getItem(key) ?? fallback;
|
|
54
|
+
}
|
|
55
|
+
function saveStored(key: string, value: string) {
|
|
56
|
+
if (typeof localStorage === "undefined") return;
|
|
57
|
+
localStorage.setItem(key, value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Strips a surrounding markdown code fence, in case the model ignores the instruction. */
|
|
61
|
+
function cleanCommitMessage(text: string): string {
|
|
62
|
+
text = text.trim();
|
|
63
|
+
let fenceMatch = text.match(/^```[^\n]*\n([\s\S]*?)\n```$/);
|
|
64
|
+
if (fenceMatch) {
|
|
65
|
+
text = fenceMatch[1].trim();
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rowButtonStyle = css.pad2(12, 8).button.bord2(0, 0, 20).fontWeight("bold");
|
|
71
|
+
|
|
72
|
+
export class CommitModal extends qreact.Component<{
|
|
73
|
+
/** True for the querysub repo, false for the main service repo. */
|
|
74
|
+
isQuerysub?: boolean;
|
|
75
|
+
/** The `git status --porcelain` lines, shown as a file list. */
|
|
76
|
+
changes: string[];
|
|
77
|
+
/** Performs the actual commit + push (and publish, for querysub). */
|
|
78
|
+
onCommit: (message: string) => Promise<void>;
|
|
79
|
+
}> {
|
|
80
|
+
state = t.state({
|
|
81
|
+
message: t.string,
|
|
82
|
+
apiKey: t.string(loadStored(STORAGE_API_KEY, "")),
|
|
83
|
+
model: t.string(loadStored(STORAGE_MODEL, DEFAULT_MODEL)),
|
|
84
|
+
effort: t.atomic(loadStored(STORAGE_EFFORT, DEFAULT_EFFORT) as OpenRouterEffort),
|
|
85
|
+
diff: t.string,
|
|
86
|
+
diffLoading: t.boolean(true),
|
|
87
|
+
summarizing: t.boolean(false),
|
|
88
|
+
committing: t.boolean(false),
|
|
89
|
+
error: t.string,
|
|
90
|
+
lastCost: t.number,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
componentDidMount() {
|
|
94
|
+
Querysub.onCommitFinished(() => {
|
|
95
|
+
void this.loadDiff();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async loadDiff() {
|
|
100
|
+
try {
|
|
101
|
+
let isQuerysub = Querysub.localRead(() => this.props.isQuerysub);
|
|
102
|
+
let controller = MachineServiceController(SocketFunction.browserNodeId());
|
|
103
|
+
let diff = await controller.getGitDiff.promise({ useQuerysub: isQuerysub });
|
|
104
|
+
Querysub.localCommit(() => {
|
|
105
|
+
this.state.diff = diff;
|
|
106
|
+
this.state.diffLoading = false;
|
|
107
|
+
});
|
|
108
|
+
} catch (e: any) {
|
|
109
|
+
Querysub.localCommit(() => {
|
|
110
|
+
this.state.error = `Failed to load changes: ${e.stack ?? e}`;
|
|
111
|
+
this.state.diffLoading = false;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async doSummarize() {
|
|
117
|
+
let { apiKey, model, effort, diff } = Querysub.localRead(() => ({
|
|
118
|
+
apiKey: this.state.apiKey,
|
|
119
|
+
model: this.state.model,
|
|
120
|
+
effort: this.state.effort,
|
|
121
|
+
diff: this.state.diff,
|
|
122
|
+
}));
|
|
123
|
+
if (!apiKey.trim()) {
|
|
124
|
+
Querysub.localCommit(() => { this.state.error = "Enter an OpenRouter API key first."; });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!diff) {
|
|
128
|
+
Querysub.localCommit(() => { this.state.error = "No changes available to summarize."; });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
Querysub.localCommit(() => {
|
|
132
|
+
this.state.summarizing = true;
|
|
133
|
+
this.state.error = "";
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
let result = await callOpenRouterModel({
|
|
137
|
+
apiKey,
|
|
138
|
+
model,
|
|
139
|
+
effort,
|
|
140
|
+
prompt: COMMIT_PROMPT.replace("{{DIFF}}", diff),
|
|
141
|
+
onProgress: partial => {
|
|
142
|
+
Querysub.localCommit(() => { this.state.message = partial; });
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
Querysub.localCommit(() => {
|
|
146
|
+
this.state.message = cleanCommitMessage(result.text);
|
|
147
|
+
this.state.lastCost = result.cost ?? 0;
|
|
148
|
+
});
|
|
149
|
+
} catch (e: any) {
|
|
150
|
+
Querysub.localCommit(() => { this.state.error = `Summarize failed: ${e.stack ?? e}`; });
|
|
151
|
+
} finally {
|
|
152
|
+
Querysub.localCommit(() => { this.state.summarizing = false; });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async doCommit() {
|
|
157
|
+
let message = Querysub.localRead(() => this.state.message).trim();
|
|
158
|
+
if (!message) {
|
|
159
|
+
Querysub.localCommit(() => { this.state.error = "Commit message is empty."; });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
Querysub.localCommit(() => {
|
|
163
|
+
this.state.committing = true;
|
|
164
|
+
this.state.error = "";
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
await this.props.onCommit(message);
|
|
168
|
+
closeAllModals();
|
|
169
|
+
} catch (e: any) {
|
|
170
|
+
Querysub.localCommit(() => {
|
|
171
|
+
this.state.error = `Commit failed: ${e.stack ?? e}`;
|
|
172
|
+
this.state.committing = false;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
render() {
|
|
178
|
+
let { diff, diffLoading, summarizing, committing } = this.state;
|
|
179
|
+
let diffBytes = diff ? new TextEncoder().encode(diff).length : 0;
|
|
180
|
+
|
|
181
|
+
let summarizeLabel: string;
|
|
182
|
+
if (diffLoading) {
|
|
183
|
+
summarizeLabel = "Summarize (loading changes…)";
|
|
184
|
+
} else if (summarizing) {
|
|
185
|
+
summarizeLabel = "Summarizing…";
|
|
186
|
+
} else {
|
|
187
|
+
summarizeLabel = `Summarize ${diffBytes.toLocaleString()} bytes of changes`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return <div className={css.vbox(12).minWidth(620)}>
|
|
191
|
+
<InputLabel
|
|
192
|
+
label="Commit message (shift+enter/shift+tab to commit, escape to cancel)"
|
|
193
|
+
value={this.state.message}
|
|
194
|
+
textarea
|
|
195
|
+
fillWidth
|
|
196
|
+
focusOnMount
|
|
197
|
+
hot
|
|
198
|
+
noEnterKeyCommit
|
|
199
|
+
alwaysUseLatestValueWhenFocused
|
|
200
|
+
style={{ height: 300 }}
|
|
201
|
+
onChangeValue={value => { this.state.message = value; }}
|
|
202
|
+
onKeyDown={e => {
|
|
203
|
+
if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
void this.doCommit();
|
|
206
|
+
}
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
|
|
210
|
+
<div className={css.hbox(10).flexWrap("wrap").alignItems("flex-end")}>
|
|
211
|
+
<button
|
|
212
|
+
className={rowButtonStyle.hsl(45, 80, 85)}
|
|
213
|
+
disabled={diffLoading || summarizing}
|
|
214
|
+
onClick={() => void this.doSummarize()}
|
|
215
|
+
>
|
|
216
|
+
🤖 {summarizeLabel}
|
|
217
|
+
</button>
|
|
218
|
+
<DropdownSelector
|
|
219
|
+
title="Model"
|
|
220
|
+
value={this.state.model}
|
|
221
|
+
onChange={value => {
|
|
222
|
+
this.state.model = value;
|
|
223
|
+
saveStored(STORAGE_MODEL, value);
|
|
224
|
+
}}
|
|
225
|
+
options={SUGGEST_MODELS.map(m => ({ value: m, label: m }))}
|
|
226
|
+
/>
|
|
227
|
+
<DropdownSelector
|
|
228
|
+
title="Thinking effort"
|
|
229
|
+
value={this.state.effort}
|
|
230
|
+
onChange={value => {
|
|
231
|
+
this.state.effort = value;
|
|
232
|
+
saveStored(STORAGE_EFFORT, value);
|
|
233
|
+
}}
|
|
234
|
+
options={EFFORT_LEVELS.map(level => ({ value: level, label: level }))}
|
|
235
|
+
/>
|
|
236
|
+
{this.state.lastCost && <span className={css.colorhsl(0, 0, 45).fontSize(12)}>
|
|
237
|
+
{formatNumber(1 / this.state.lastCost)} / USD
|
|
238
|
+
</span>}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<InputLabel
|
|
242
|
+
label="OpenRouter API Key"
|
|
243
|
+
value={this.state.apiKey}
|
|
244
|
+
type="password"
|
|
245
|
+
fillWidth
|
|
246
|
+
hot
|
|
247
|
+
onChangeValue={value => {
|
|
248
|
+
this.state.apiKey = value;
|
|
249
|
+
saveStored(STORAGE_API_KEY, value);
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
{this.state.error && <div className={
|
|
254
|
+
css.pad2(10).bord2(0, 80, 50).hsl(0, 80, 95).colorhsl(0, 80, 30).whiteSpace("pre-wrap")
|
|
255
|
+
}>
|
|
256
|
+
⚠️ {this.state.error}
|
|
257
|
+
</div>}
|
|
258
|
+
|
|
259
|
+
<div className={css.hbox(10)}>
|
|
260
|
+
<button
|
|
261
|
+
className={rowButtonStyle.hsl(120, 70, 85)}
|
|
262
|
+
disabled={committing}
|
|
263
|
+
onClick={() => void this.doCommit()}
|
|
264
|
+
>
|
|
265
|
+
{committing ? "Committing…" : "⬆️ Commit & Push"}
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div className={css.vbox(5)}>
|
|
270
|
+
{this.props.changes.map(change => <div>{change}</div>)}
|
|
271
|
+
</div>
|
|
272
|
+
</div>;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -2,13 +2,12 @@ import { SocketFunction } from "socket-function/SocketFunction";
|
|
|
2
2
|
import { qreact } from "../../4-dom/qreact";
|
|
3
3
|
import { MachineServiceController, MachineInfo, ServiceConfig } from "../machineSchema";
|
|
4
4
|
import { showFullscreenModal } from "../../5-diagnostics/FullscreenModal";
|
|
5
|
-
import { InputLabel } from "../../library-components/InputLabel";
|
|
6
5
|
import { css } from "../../4-dom/css";
|
|
7
6
|
import { formatTime } from "socket-function/src/formatting/format";
|
|
8
7
|
import { formatDateJSX } from "../../misc/formatJSX";
|
|
9
8
|
import { MachineController } from "../machineController";
|
|
10
|
-
import { closeAllModals } from "../../5-diagnostics/Modal";
|
|
11
9
|
import { unique } from "../../misc";
|
|
10
|
+
import { CommitModal } from "./CommitModal";
|
|
12
11
|
|
|
13
12
|
module.hotreload = true;
|
|
14
13
|
|
|
@@ -56,32 +55,13 @@ export class UpdateButtons extends qreact.Component<{
|
|
|
56
55
|
title={gitInfo?.querysubUncommitted.join("\n")}
|
|
57
56
|
onClick={() => {
|
|
58
57
|
showFullscreenModal({
|
|
59
|
-
content: <
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
noEnterKeyCommit
|
|
67
|
-
onKeyDown={async e => {
|
|
68
|
-
let value = e.currentTarget.value;
|
|
69
|
-
if (!value) return;
|
|
70
|
-
if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
|
|
71
|
-
e.preventDefault();
|
|
72
|
-
await controller.commitPushAndPublishQuerysub.promise(value);
|
|
73
|
-
closeAllModals();
|
|
74
|
-
}
|
|
75
|
-
}}
|
|
76
|
-
/>
|
|
77
|
-
<div className={css.vbox(5)}>
|
|
78
|
-
{gitInfo?.querysubUncommitted.map(change => {
|
|
79
|
-
return <div>
|
|
80
|
-
{change}
|
|
81
|
-
</div>;
|
|
82
|
-
})}
|
|
83
|
-
</div>
|
|
84
|
-
</div>
|
|
58
|
+
content: <CommitModal
|
|
59
|
+
isQuerysub
|
|
60
|
+
changes={gitInfo?.querysubUncommitted ?? []}
|
|
61
|
+
onCommit={async message => {
|
|
62
|
+
await controller.commitPushAndPublishQuerysub.promise(message);
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
85
65
|
});
|
|
86
66
|
}}
|
|
87
67
|
>
|
|
@@ -99,32 +79,12 @@ export class UpdateButtons extends qreact.Component<{
|
|
|
99
79
|
title={gitInfo?.uncommitted.join("\n")}
|
|
100
80
|
onClick={() => {
|
|
101
81
|
showFullscreenModal({
|
|
102
|
-
content: <
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
focusOnMount
|
|
109
|
-
noEnterKeyCommit
|
|
110
|
-
onKeyDown={async e => {
|
|
111
|
-
let value = e.currentTarget.value;
|
|
112
|
-
if (!value) return;
|
|
113
|
-
if (e.key === "Enter" && e.shiftKey || e.key === "Tab" && e.shiftKey) {
|
|
114
|
-
e.preventDefault();
|
|
115
|
-
await controller.commitPushService.promise(value);
|
|
116
|
-
closeAllModals();
|
|
117
|
-
}
|
|
118
|
-
}}
|
|
119
|
-
/>
|
|
120
|
-
<div className={css.vbox(5)}>
|
|
121
|
-
{gitInfo?.uncommitted.map(change => {
|
|
122
|
-
return <div>
|
|
123
|
-
{change}
|
|
124
|
-
</div>;
|
|
125
|
-
})}
|
|
126
|
-
</div>
|
|
127
|
-
</div>
|
|
82
|
+
content: <CommitModal
|
|
83
|
+
changes={gitInfo?.uncommitted ?? []}
|
|
84
|
+
onCommit={async message => {
|
|
85
|
+
await controller.commitPushService.promise(message);
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
128
88
|
});
|
|
129
89
|
}}
|
|
130
90
|
>
|