dsh-plugin-jules 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +335 -0
- package/cordis.patch.yml +37 -0
- package/lib/async.d.ts +11 -0
- package/lib/async.js +27 -0
- package/lib/client.d.ts +207 -0
- package/lib/client.js +368 -0
- package/lib/index.d.ts +107 -0
- package/lib/index.js +234 -0
- package/lib/journal.d.ts +78 -0
- package/lib/journal.js +110 -0
- package/lib/tools.d.ts +43 -0
- package/lib/tools.js +576 -0
- package/lib/types.d.ts +230 -0
- package/lib/types.js +128 -0
- package/lib/views.d.ts +240 -0
- package/lib/views.js +438 -0
- package/lib/watch.d.ts +87 -0
- package/lib/watch.js +387 -0
- package/package.json +86 -0
- package/scripts/build.mjs +37 -0
- package/scripts/link-dsh-deps.mjs +132 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Jules as a harness capability: the `jules_*` tool family lets a model
|
|
3
|
+
* delegate a coding task to the remote Jules agent, follow it, approve its plan,
|
|
4
|
+
* and retrieve the diff or pull request it produced.
|
|
5
|
+
*
|
|
6
|
+
* The plugin speaks the documented Jules v1alpha REST API directly. The Jules
|
|
7
|
+
* CLI is deliberately not used: it authenticates through an interactive OAuth
|
|
8
|
+
* flow in the OS keyring, reads no `JULES_*` environment variable, produces no
|
|
9
|
+
* machine-readable output, and drives an internal backend rather than the
|
|
10
|
+
* documented one — none of which survives contact with an unattended harness
|
|
11
|
+
* process.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-plugin-jules
|
|
14
|
+
*/
|
|
15
|
+
import z from '@deepseek-ai/schemastery';
|
|
16
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
17
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
18
|
+
import { JulesClient } from "./client.js";
|
|
19
|
+
import { registerJulesTools } from "./tools.js";
|
|
20
|
+
import { openWatchJournal, orphanNote } from "./journal.js";
|
|
21
|
+
import { registerJulesWatch } from "./watch.js";
|
|
22
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
23
|
+
export const name = 'jules';
|
|
24
|
+
/** The tool registry, and the system prompt the guidance section joins. */
|
|
25
|
+
export const inject = ['tools', 'systemPrompt'];
|
|
26
|
+
/** Credential name read when the configuration does not name another one. */
|
|
27
|
+
export const DEFAULT_API_KEY_ENV = 'JULES_API_KEY';
|
|
28
|
+
/** API root. Jules exposes one documented version, `v1alpha`. */
|
|
29
|
+
export const DEFAULT_BASE_URL = 'https://jules.googleapis.com/v1alpha';
|
|
30
|
+
/** Where a user creates the API key this plugin reads. */
|
|
31
|
+
export const API_KEY_URL = 'https://jules.google.com/settings';
|
|
32
|
+
/** Default per-request deadline. */
|
|
33
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
34
|
+
/** Default page size for list calls; the service default is 30. */
|
|
35
|
+
export const DEFAULT_PAGE_SIZE = 30;
|
|
36
|
+
/** Largest page size the service accepts. */
|
|
37
|
+
export const MAX_PAGE_SIZE = 100;
|
|
38
|
+
/** Default cap on a diff returned by `jules_patch`. */
|
|
39
|
+
export const DEFAULT_MAX_PATCH_BYTES = 200_000;
|
|
40
|
+
/** Default wait budget for `jules_wait`. */
|
|
41
|
+
export const DEFAULT_WAIT_MS = 120_000;
|
|
42
|
+
/** Largest wait budget a caller may request. */
|
|
43
|
+
export const MAX_WAIT_MS = 300_000;
|
|
44
|
+
/** Delay between two `jules_wait` status polls. */
|
|
45
|
+
export const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
46
|
+
/** Pages of activities one read may walk. */
|
|
47
|
+
export const DEFAULT_MAX_ACTIVITY_PAGES = 10;
|
|
48
|
+
/** Default background watch budget for `jules_watch`. */
|
|
49
|
+
export const DEFAULT_WATCH_MS = 1_800_000;
|
|
50
|
+
/** Largest background watch budget a caller may request. */
|
|
51
|
+
export const MAX_WATCH_MS = 7_200_000;
|
|
52
|
+
/** Delay between two background watch polls. */
|
|
53
|
+
export const DEFAULT_WATCH_POLL_INTERVAL_MS = 15_000;
|
|
54
|
+
/** Retry attempts for a rate-limited or failing request. */
|
|
55
|
+
export const DEFAULT_RETRY_MAX_ATTEMPTS = 4;
|
|
56
|
+
/** First backoff step for a retried request. */
|
|
57
|
+
export const DEFAULT_RETRY_BASE_DELAY_MS = 1_000;
|
|
58
|
+
/** Ceiling for one backoff step. */
|
|
59
|
+
export const DEFAULT_RETRY_MAX_DELAY_MS = 30_000;
|
|
60
|
+
/** Schemastery configuration for loader defaults and the generated config catalog. */
|
|
61
|
+
export const Config = z.object({
|
|
62
|
+
apiKey: z.string().role('secret'),
|
|
63
|
+
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
|
|
64
|
+
baseURL: z.string().default(DEFAULT_BASE_URL),
|
|
65
|
+
defaultSource: z.string(),
|
|
66
|
+
requestTimeoutMs: z.number().step(1).min(1).default(DEFAULT_REQUEST_TIMEOUT_MS),
|
|
67
|
+
defaultPageSize: z.number().step(1).min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
|
|
68
|
+
maxPageSize: z.number().step(1).min(1).max(MAX_PAGE_SIZE).default(MAX_PAGE_SIZE),
|
|
69
|
+
maxPatchBytes: z.number().step(1).min(1_000).default(DEFAULT_MAX_PATCH_BYTES),
|
|
70
|
+
waitDefaultMs: z.number().step(1).min(1_000).default(DEFAULT_WAIT_MS),
|
|
71
|
+
waitMaxMs: z.number().step(1).min(1_000).default(MAX_WAIT_MS),
|
|
72
|
+
pollIntervalMs: z.number().step(1).min(250).default(DEFAULT_POLL_INTERVAL_MS),
|
|
73
|
+
maxActivityPages: z.number().step(1).min(1).default(DEFAULT_MAX_ACTIVITY_PAGES),
|
|
74
|
+
enableWatch: z.boolean().default(true),
|
|
75
|
+
watchDefaultMs: z.number().step(1).min(30_000).default(DEFAULT_WATCH_MS),
|
|
76
|
+
watchMaxMs: z.number().step(1).min(30_000).default(MAX_WATCH_MS),
|
|
77
|
+
watchPollIntervalMs: z.number().step(1).min(250).default(DEFAULT_WATCH_POLL_INTERVAL_MS),
|
|
78
|
+
watchSettleOnMessage: z.boolean().default(true),
|
|
79
|
+
retryMaxAttempts: z.number().step(1).min(1).default(DEFAULT_RETRY_MAX_ATTEMPTS),
|
|
80
|
+
retryBaseDelayMs: z.number().step(1).min(1).default(DEFAULT_RETRY_BASE_DELAY_MS),
|
|
81
|
+
retryMaxDelayMs: z.number().step(1).min(1).default(DEFAULT_RETRY_MAX_DELAY_MS),
|
|
82
|
+
});
|
|
83
|
+
/** Model guidance placed beside the subagent instructions. */
|
|
84
|
+
export const JULES_PROMPT = 'Jules is a remote coding agent reachable through the jules_* tools. It runs asynchronously in the cloud on its own clone of a '
|
|
85
|
+
+ 'repository, so a session keeps working after your turn ends. '
|
|
86
|
+
+ 'DO NOT POLL. jules_create starts a session and returns its id; jules_watch registers a background watch and returns a job id; '
|
|
87
|
+
+ 'then END YOUR TURN and get on with something else. The watch notice wakes you when the session finishes, fails, needs a plan '
|
|
88
|
+
+ 'decision, or posts a message, and job_output reads what happened. Nothing needs checking in between — waiting is the watcher\'s '
|
|
89
|
+
+ 'job, not yours, and a status call that returns what you already saw has cost a turn and bought nothing. '
|
|
90
|
+
+ 'jules_wait is the same wait held open in the foreground; use it only when you genuinely have nothing else to do. '
|
|
91
|
+
+ 'jules_status is for confirming an action you just took, following up a notice, or answering the user when they ask about a '
|
|
92
|
+
+ 'session — never for waiting. '
|
|
93
|
+
+ 'The rest: jules_sources lists the repositories Jules may work in; jules_activities reads the event log, for diagnosing a stall '
|
|
94
|
+
+ 'rather than polling; jules_approve_plan releases a plan you have reviewed; jules_send_message answers or corrects the agent; '
|
|
95
|
+
+ 'jules_patch returns the unified diff, in slices when it is large. '
|
|
96
|
+
+ 'Ask for requirePlanApproval when a task will change existing code, then review the plan before approving it. '
|
|
97
|
+
+ 'Prefer finishing small work here; delegate a task that is long, independent, or better done in a clean checkout, and do not '
|
|
98
|
+
+ 'open several sessions for one task because the service throttles concurrent creation.';
|
|
99
|
+
/**
|
|
100
|
+
* Complete every optional field and reject combinations the schema cannot express.
|
|
101
|
+
* @param config - the validated configuration section.
|
|
102
|
+
* @returns the fully defaulted bounds the plugin runs with.
|
|
103
|
+
*/
|
|
104
|
+
function resolveConfig(config) {
|
|
105
|
+
const resolved = {
|
|
106
|
+
apiKey: config.apiKey,
|
|
107
|
+
defaultSource: config.defaultSource,
|
|
108
|
+
apiKeyEnv: config.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
|
|
109
|
+
baseURL: (config.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
110
|
+
requestTimeoutMs: config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
111
|
+
defaultPageSize: config.defaultPageSize ?? DEFAULT_PAGE_SIZE,
|
|
112
|
+
maxPageSize: config.maxPageSize ?? MAX_PAGE_SIZE,
|
|
113
|
+
maxPatchBytes: config.maxPatchBytes ?? DEFAULT_MAX_PATCH_BYTES,
|
|
114
|
+
waitDefaultMs: config.waitDefaultMs ?? DEFAULT_WAIT_MS,
|
|
115
|
+
waitMaxMs: config.waitMaxMs ?? MAX_WAIT_MS,
|
|
116
|
+
pollIntervalMs: config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
117
|
+
maxActivityPages: config.maxActivityPages ?? DEFAULT_MAX_ACTIVITY_PAGES,
|
|
118
|
+
enableWatch: config.enableWatch ?? true,
|
|
119
|
+
watchDefaultMs: config.watchDefaultMs ?? DEFAULT_WATCH_MS,
|
|
120
|
+
watchMaxMs: config.watchMaxMs ?? MAX_WATCH_MS,
|
|
121
|
+
watchPollIntervalMs: config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS,
|
|
122
|
+
watchSettleOnMessage: config.watchSettleOnMessage ?? true,
|
|
123
|
+
retryMaxAttempts: config.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS,
|
|
124
|
+
retryBaseDelayMs: config.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS,
|
|
125
|
+
retryMaxDelayMs: config.retryMaxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
|
|
126
|
+
};
|
|
127
|
+
if (resolved.defaultPageSize > resolved.maxPageSize) {
|
|
128
|
+
throw new Error('jules: defaultPageSize must not exceed maxPageSize');
|
|
129
|
+
}
|
|
130
|
+
if (resolved.waitDefaultMs > resolved.waitMaxMs) {
|
|
131
|
+
throw new Error('jules: waitDefaultMs must not exceed waitMaxMs');
|
|
132
|
+
}
|
|
133
|
+
if (resolved.watchDefaultMs > resolved.watchMaxMs) {
|
|
134
|
+
throw new Error('jules: watchDefaultMs must not exceed watchMaxMs');
|
|
135
|
+
}
|
|
136
|
+
if (resolved.retryBaseDelayMs > resolved.retryMaxDelayMs) {
|
|
137
|
+
throw new Error('jules: retryBaseDelayMs must not exceed retryMaxDelayMs');
|
|
138
|
+
}
|
|
139
|
+
if (!URL.canParse(resolved.baseURL)) {
|
|
140
|
+
throw new Error(`jules: baseURL must be an absolute URL, received ${JSON.stringify(resolved.baseURL)}`);
|
|
141
|
+
}
|
|
142
|
+
return resolved;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Register the Jules tool family and its model guidance.
|
|
146
|
+
* @param ctx - plugin context supplying the tool registry and credential seam.
|
|
147
|
+
* @param config - validated configuration section.
|
|
148
|
+
*/
|
|
149
|
+
export function apply(ctx, config) {
|
|
150
|
+
const resolved = resolveConfig(config);
|
|
151
|
+
const apiKeyEnv = credentialRef(resolved.apiKeyEnv);
|
|
152
|
+
const literalApiKey = resolved.apiKey !== undefined && resolved.apiKey.length > 0
|
|
153
|
+
? resolved.apiKey
|
|
154
|
+
: undefined;
|
|
155
|
+
const client = new JulesClient({
|
|
156
|
+
resolveApiKey: async () => {
|
|
157
|
+
if (literalApiKey !== undefined)
|
|
158
|
+
return literalApiKey;
|
|
159
|
+
const credentials = ctx.get('credentials');
|
|
160
|
+
if (credentials !== undefined) {
|
|
161
|
+
const record = await credentials.resolve(apiKeyEnv);
|
|
162
|
+
if (record !== undefined && record.value.length > 0)
|
|
163
|
+
return record.value;
|
|
164
|
+
}
|
|
165
|
+
// Without the credential seam, the launch environment is the whole
|
|
166
|
+
// credential plane — the same fallback the in-box providers take.
|
|
167
|
+
const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
|
|
168
|
+
return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined;
|
|
169
|
+
},
|
|
170
|
+
baseURL: resolved.baseURL,
|
|
171
|
+
requestTimeoutMs: resolved.requestTimeoutMs,
|
|
172
|
+
defaultPageSize: resolved.defaultPageSize,
|
|
173
|
+
maxPageSize: resolved.maxPageSize,
|
|
174
|
+
retry: {
|
|
175
|
+
maxAttempts: resolved.retryMaxAttempts,
|
|
176
|
+
baseDelayMs: resolved.retryBaseDelayMs,
|
|
177
|
+
maxDelayMs: resolved.retryMaxDelayMs,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
registerJulesTools(ctx, client, {
|
|
181
|
+
maxPatchBytes: resolved.maxPatchBytes,
|
|
182
|
+
waitDefaultMs: resolved.waitDefaultMs,
|
|
183
|
+
waitMaxMs: resolved.waitMaxMs,
|
|
184
|
+
pollIntervalMs: resolved.pollIntervalMs,
|
|
185
|
+
maxActivityPages: resolved.maxActivityPages,
|
|
186
|
+
settleOnMessage: resolved.watchSettleOnMessage,
|
|
187
|
+
defaultSource: resolved.defaultSource ?? '',
|
|
188
|
+
});
|
|
189
|
+
// Watches are process-local and cannot be restored before an agent exists to
|
|
190
|
+
// own one, so the journal only preserves what was in flight.
|
|
191
|
+
//
|
|
192
|
+
// Injected rather than read with ctx.get(): activation here is
|
|
193
|
+
// service-availability driven and does not follow row order, so the storage
|
|
194
|
+
// domain can arrive after this plugin applies. Reading it eagerly raced and
|
|
195
|
+
// silently disabled the journal — no records, and no error either.
|
|
196
|
+
let journal;
|
|
197
|
+
// Sessions this process is actively watching. The note is about watches that
|
|
198
|
+
// survived a restart, and a watch running right now is not one of them.
|
|
199
|
+
const live = new Set();
|
|
200
|
+
ctx.inject(['storageDomain'], (storageCtx) => {
|
|
201
|
+
void openWatchJournal(storageCtx)
|
|
202
|
+
.then((opened) => { journal = opened; })
|
|
203
|
+
.catch((error) => {
|
|
204
|
+
storageCtx.logger.warn(`jules: could not open the watch journal: ${String(error)}`);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
if (resolved.enableWatch) {
|
|
208
|
+
// Registered through an optional injection: a composition without the job
|
|
209
|
+
// registry still gets the other nine tools instead of holding the whole
|
|
210
|
+
// plugin PENDING on a service it may never provide.
|
|
211
|
+
const watchConfig = {
|
|
212
|
+
pollIntervalMs: resolved.watchPollIntervalMs,
|
|
213
|
+
defaultMs: resolved.watchDefaultMs,
|
|
214
|
+
maxMs: resolved.watchMaxMs,
|
|
215
|
+
maxActivityPages: resolved.maxActivityPages,
|
|
216
|
+
settleOnMessage: resolved.watchSettleOnMessage,
|
|
217
|
+
};
|
|
218
|
+
ctx.inject(['jobs'], (jobsCtx) => {
|
|
219
|
+
registerJulesWatch(jobsCtx, client, watchConfig, () => journal, live);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
ctx.systemPrompt.section({
|
|
223
|
+
name: 'tool:jules',
|
|
224
|
+
// Borrows the subagent slot deliberately. The order table is a fixed set of
|
|
225
|
+
// positions owned by particular tool families, and Jules is the same kind of
|
|
226
|
+
// thing a subagent is — a delegation target — so its guidance belongs beside
|
|
227
|
+
// those instructions rather than in an unrelated slot. Equal orders fall back
|
|
228
|
+
// to name order, which is stable.
|
|
229
|
+
order: ctx.systemPrompt.getSectionOrder('TOOL_SUBAGENT'),
|
|
230
|
+
// Evaluated per assembly, so the orphan note appears the moment the plugin
|
|
231
|
+
// loads after a restart and disappears as watches are re-armed.
|
|
232
|
+
text: () => JULES_PROMPT + orphanNote((journal?.pending() ?? []).filter(record => !live.has(record.session))),
|
|
233
|
+
});
|
|
234
|
+
}
|
package/lib/journal.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable half of watching: a record of what was being watched.
|
|
3
|
+
*
|
|
4
|
+
* Background jobs are process-local. The registry holds them in memory and
|
|
5
|
+
* tears every one down when its service disposes, so a harness restart ends
|
|
6
|
+
* every watch silently — while the Jules sessions themselves carry on in
|
|
7
|
+
* Google's cloud, because they were never ours to begin with.
|
|
8
|
+
*
|
|
9
|
+
* Nothing can bring the job back. A job needs a live owning agent to receive a
|
|
10
|
+
* completion notice, and at plugin load there is no agent, so this journal does
|
|
11
|
+
* not pretend to restore one. It preserves the *fact* of what was being
|
|
12
|
+
* watched, which the prompt turns into an instruction to re-arm — and which
|
|
13
|
+
* clears itself, because an entry is only interesting until the watch budget it
|
|
14
|
+
* recorded would have run out anyway.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-plugin-jules/journal
|
|
17
|
+
*/
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
19
|
+
/** One armed watch, as it needs to survive a restart. */
|
|
20
|
+
export interface WatchRecord {
|
|
21
|
+
/** Bare Jules session id. */
|
|
22
|
+
session: string;
|
|
23
|
+
/** Epoch ms the watch was armed. */
|
|
24
|
+
startedAt: number;
|
|
25
|
+
/** Epoch ms after which the watch would have given up on its own. */
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The watch journal domain.
|
|
30
|
+
*
|
|
31
|
+
* `backup-and-skip` because this is a re-arm hint and nothing more: one unreadable
|
|
32
|
+
* record must never cost the plugin its load.
|
|
33
|
+
*/
|
|
34
|
+
export declare const julesWatchDomainSpec: {
|
|
35
|
+
name: string;
|
|
36
|
+
version: number;
|
|
37
|
+
invalidRecords: "backup-and-skip";
|
|
38
|
+
layout: "per-record";
|
|
39
|
+
tables: {
|
|
40
|
+
watches: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, WatchRecord>;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Records an active watch, so a restart can still say what was in flight.
|
|
45
|
+
*
|
|
46
|
+
* Writes never reject. A journal failure costs the restart note, never the
|
|
47
|
+
* watch, so it must not take down the job that is doing the real work — but it
|
|
48
|
+
* is logged, because swallowing it silently is exactly how this went unnoticed
|
|
49
|
+
* for a release.
|
|
50
|
+
*/
|
|
51
|
+
export interface WatchJournal {
|
|
52
|
+
/** Record a freshly armed watch, replacing any earlier entry for that session. Never rejects. */
|
|
53
|
+
arm(record: WatchRecord): Promise<void>;
|
|
54
|
+
/** Forget one session, once its watch has settled for any reason. Never rejects. */
|
|
55
|
+
release(session: string): Promise<void>;
|
|
56
|
+
/** Every record whose watch was still running, oldest first. */
|
|
57
|
+
pending(now?: number): WatchRecord[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Describe the watches a restart orphaned.
|
|
61
|
+
*
|
|
62
|
+
* A live job cannot exist before an agent does, so the most this can do is tell
|
|
63
|
+
* the next agent what to re-arm. Returns an empty string when there is nothing
|
|
64
|
+
* to report, which keeps the prompt section unchanged in the common case.
|
|
65
|
+
* @param records - records the journal still holds.
|
|
66
|
+
* @returns the paragraph to append to the model guidance, or ''.
|
|
67
|
+
*/
|
|
68
|
+
export declare function orphanNote(records: readonly WatchRecord[]): string;
|
|
69
|
+
/**
|
|
70
|
+
* Open the journal over the mounted storage domain.
|
|
71
|
+
*
|
|
72
|
+
* A composition without the domain form still gets the whole tool family — it
|
|
73
|
+
* simply loses the ability to say what was being watched before a restart, which
|
|
74
|
+
* is why this answers `undefined` rather than failing the plugin.
|
|
75
|
+
* @param ctx - plugin context supplying the storage domain form.
|
|
76
|
+
* @returns the journal, or undefined when no storage is mounted.
|
|
77
|
+
*/
|
|
78
|
+
export declare function openWatchJournal(ctx: Context): Promise<WatchJournal | undefined>;
|
package/lib/journal.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable half of watching: a record of what was being watched.
|
|
3
|
+
*
|
|
4
|
+
* Background jobs are process-local. The registry holds them in memory and
|
|
5
|
+
* tears every one down when its service disposes, so a harness restart ends
|
|
6
|
+
* every watch silently — while the Jules sessions themselves carry on in
|
|
7
|
+
* Google's cloud, because they were never ours to begin with.
|
|
8
|
+
*
|
|
9
|
+
* Nothing can bring the job back. A job needs a live owning agent to receive a
|
|
10
|
+
* completion notice, and at plugin load there is no agent, so this journal does
|
|
11
|
+
* not pretend to restore one. It preserves the *fact* of what was being
|
|
12
|
+
* watched, which the prompt turns into an instruction to re-arm — and which
|
|
13
|
+
* clears itself, because an entry is only interesting until the watch budget it
|
|
14
|
+
* recorded would have run out anyway.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-plugin-jules/journal
|
|
17
|
+
*/
|
|
18
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
/** The durable shape of one record, validated at open and on every write. */
|
|
21
|
+
const watchRecord = z.object({
|
|
22
|
+
session: z.string(),
|
|
23
|
+
startedAt: z.number().int().nonnegative(),
|
|
24
|
+
expiresAt: z.number().int().nonnegative(),
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* The watch journal domain.
|
|
28
|
+
*
|
|
29
|
+
* `backup-and-skip` because this is a re-arm hint and nothing more: one unreadable
|
|
30
|
+
* record must never cost the plugin its load.
|
|
31
|
+
*/
|
|
32
|
+
export const julesWatchDomainSpec = defineDomain({
|
|
33
|
+
name: 'jules_watches',
|
|
34
|
+
version: 1,
|
|
35
|
+
invalidRecords: 'backup-and-skip',
|
|
36
|
+
layout: 'per-record',
|
|
37
|
+
tables: { watches: domainTable(watchRecord) },
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Describe the watches a restart orphaned.
|
|
41
|
+
*
|
|
42
|
+
* A live job cannot exist before an agent does, so the most this can do is tell
|
|
43
|
+
* the next agent what to re-arm. Returns an empty string when there is nothing
|
|
44
|
+
* to report, which keeps the prompt section unchanged in the common case.
|
|
45
|
+
* @param records - records the journal still holds.
|
|
46
|
+
* @returns the paragraph to append to the model guidance, or ''.
|
|
47
|
+
*/
|
|
48
|
+
export function orphanNote(records) {
|
|
49
|
+
if (records.length === 0)
|
|
50
|
+
return '';
|
|
51
|
+
const ids = records.map(record => record.session).join(', ');
|
|
52
|
+
return '\n\nNote: background watches do not survive a harness restart, and '
|
|
53
|
+
+ (records.length === 1 ? 'one watch was' : records.length + ' watches were')
|
|
54
|
+
+ ' still armed when this harness last stopped: ' + ids + '. '
|
|
55
|
+
+ 'The Jules sessions themselves kept running in the cloud. Re-arm the ones you still care about with '
|
|
56
|
+
+ 'jules_watch; a session that has since finished needs only a jules_status.';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Open the journal over the mounted storage domain.
|
|
60
|
+
*
|
|
61
|
+
* A composition without the domain form still gets the whole tool family — it
|
|
62
|
+
* simply loses the ability to say what was being watched before a restart, which
|
|
63
|
+
* is why this answers `undefined` rather than failing the plugin.
|
|
64
|
+
* @param ctx - plugin context supplying the storage domain form.
|
|
65
|
+
* @returns the journal, or undefined when no storage is mounted.
|
|
66
|
+
*/
|
|
67
|
+
export async function openWatchJournal(ctx) {
|
|
68
|
+
const storageDomain = ctx.get('storageDomain');
|
|
69
|
+
if (storageDomain === undefined)
|
|
70
|
+
return undefined;
|
|
71
|
+
const domain = await storageDomain.open(julesWatchDomainSpec);
|
|
72
|
+
ctx.effect(() => () => domain.close(), 'jules.watchJournal.close');
|
|
73
|
+
const table = domain.table('watches');
|
|
74
|
+
const warn = (message, error) => {
|
|
75
|
+
ctx.logger.warn(`jules: ${message}: ${String(error)}`);
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
async arm(record) {
|
|
79
|
+
try {
|
|
80
|
+
await table.put(record.session, record);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
warn(`could not record the watch for session ${record.session}`, error);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async release(session) {
|
|
87
|
+
try {
|
|
88
|
+
await table.delete(session);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
warn(`could not release the watch for session ${session}`, error);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
pending(now = Date.now()) {
|
|
95
|
+
const live = [];
|
|
96
|
+
for (const [, record] of table.entries()) {
|
|
97
|
+
if (record.expiresAt > now)
|
|
98
|
+
live.push(record);
|
|
99
|
+
// Expired entries are no longer interesting, so drop them rather than
|
|
100
|
+
// let a dead watch keep reporting itself forever.
|
|
101
|
+
else {
|
|
102
|
+
table.delete(record.session).catch((error) => {
|
|
103
|
+
warn(`could not prune the expired watch for session ${record.session}`, error);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return live.sort((left, right) => left.startedAt - right.startedAt);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/lib/tools.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing `jules_*` tool family.
|
|
3
|
+
*
|
|
4
|
+
* Every tool returns a canonical value described by its own output schema and
|
|
5
|
+
* renders that value to text separately, so a caller that needs an id or a
|
|
6
|
+
* state never has to parse the prose.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-plugin-jules/tools
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import type { JulesClient } from './client.ts';
|
|
12
|
+
/** Bounds the tool family applies to its own inputs. */
|
|
13
|
+
export interface JulesToolConfig {
|
|
14
|
+
/** Largest unified diff `jules_patch` will return. */
|
|
15
|
+
maxPatchBytes: number;
|
|
16
|
+
/** Wait budget `jules_wait` uses when the caller does not choose one. */
|
|
17
|
+
waitDefaultMs: number;
|
|
18
|
+
/** Upper bound a caller may raise the wait budget to. */
|
|
19
|
+
waitMaxMs: number;
|
|
20
|
+
/** Delay between two status polls inside `jules_wait`. */
|
|
21
|
+
pollIntervalMs: number;
|
|
22
|
+
/** Pages of activities a single read may walk. */
|
|
23
|
+
maxActivityPages: number;
|
|
24
|
+
/** End a wait or watch when the agent posts a message, not only on a state change. */
|
|
25
|
+
settleOnMessage: boolean;
|
|
26
|
+
/** Repository `jules_create` targets when the call omits one, as `owner/repo`. */
|
|
27
|
+
defaultSource: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Register every `jules_*` tool.
|
|
31
|
+
*
|
|
32
|
+
* Concurrency classification is a rule, not a per-tool judgement: every
|
|
33
|
+
* read-only tool declares itself concurrency-safe so sibling calls may overlap,
|
|
34
|
+
* and exactly the three that ask the service to change something —
|
|
35
|
+
* `jules_create`, `jules_approve_plan`, `jules_send_message` — leave it unset so
|
|
36
|
+
* the pipeline serializes them against their siblings. Marking a mutating tool
|
|
37
|
+
* safe would let two approvals race, and marking a reader unsafe would make a
|
|
38
|
+
* batch of status checks run one at a time for no reason.
|
|
39
|
+
* @param ctx - plugin context supplying the tool registry.
|
|
40
|
+
* @param client - the configured Jules client.
|
|
41
|
+
* @param config - bounds applied to tool inputs.
|
|
42
|
+
*/
|
|
43
|
+
export declare function registerJulesTools(ctx: Context, client: JulesClient, config: JulesToolConfig): void;
|