@patimweb/pi-zep 1.0.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/src/session.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Wiring between stored configuration and a live ZepClient.
3
+ *
4
+ * A cached session is tried first and verified with a cheap read. Only when
5
+ * that fails do we log in again - important because ZEP locks an account
6
+ * after 5 failed password attempts.
7
+ */
8
+
9
+ import { clearSession, getActiveProfileName, getProfile, getSession, saveSession } from "./config.ts";
10
+ import { ZepClient, ZepError } from "./clients/zep-client.ts";
11
+
12
+ export interface ClientHandle {
13
+ readonly client: ZepClient;
14
+ readonly profileName: string;
15
+ /** How the current session was obtained. */
16
+ readonly sessionSource: "cache" | "fresh";
17
+ }
18
+
19
+ export async function createClient(profileName?: string): Promise<ClientHandle> {
20
+ const name = profileName ?? getActiveProfileName();
21
+ if (!name) {
22
+ throw new ZepError("No ZEP profile configured. Call zep_setup first.");
23
+ }
24
+ const account = getProfile(name);
25
+
26
+ let source: "cache" | "fresh" = "cache";
27
+ const client = new ZepClient(account, {
28
+ onSession: (session) => saveSession(name, session),
29
+ });
30
+
31
+ const cached = getSession(name);
32
+ if (cached) {
33
+ client.applySession(cached);
34
+ try {
35
+ const alive = await client.probe();
36
+ if (alive) {
37
+ return { client, profileName: name, sessionSource: "cache" };
38
+ }
39
+ } catch {
40
+ // fall through to a fresh login
41
+ }
42
+ clearSession(name);
43
+ }
44
+
45
+ source = "fresh";
46
+ const result = await client.login();
47
+ if (!result.ok) {
48
+ throw new ZepError(
49
+ `${result.message ?? "Login failed"}${
50
+ result.attemptsLeft !== undefined
51
+ ? ` - ${result.attemptsLeft} password attempt(s) left before ZEP locks the account`
52
+ : ""
53
+ }`,
54
+ result.attemptsLeft,
55
+ );
56
+ }
57
+ return { client, profileName: name, sessionSource: source };
58
+ }
59
+
60
+ /**
61
+ * Force a fresh login for a profile, discarding any cached session.
62
+ * Used by zep_login force=true.
63
+ */
64
+ export async function loginProfile(profileName: string): Promise<ZepClient> {
65
+ const account = getProfile(profileName);
66
+ clearSession(profileName);
67
+
68
+ const client = new ZepClient(account, {
69
+ onSession: (session) => saveSession(profileName, session),
70
+ });
71
+
72
+ const result = await client.login();
73
+ if (!result.ok) {
74
+ throw new ZepError(
75
+ `${result.message ?? "Login failed"}${
76
+ result.attemptsLeft !== undefined
77
+ ? ` - ${result.attemptsLeft} password attempt(s) left before ZEP locks the account`
78
+ : ""
79
+ }`,
80
+ result.attemptsLeft,
81
+ );
82
+ }
83
+ return client;
84
+ }
85
+
86
+ export { ZepError };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Small helpers shared by the ZEP tools.
3
+ */
4
+
5
+ export interface ToolResult {
6
+ content: Array<{ type: "text"; text: string }>;
7
+ details: Record<string, unknown>;
8
+ }
9
+
10
+ export function textResult(text: string, details: Record<string, unknown> = {}): ToolResult {
11
+ return { content: [{ type: "text", text }], details };
12
+ }
13
+
14
+ export function errorResult(text: string, details: Record<string, unknown> = {}): ToolResult {
15
+ return { content: [{ type: "text", text: `Error: ${text}` }], details: { ...details, error: true } };
16
+ }
17
+
18
+ /** Normalise ISO-ish date input; falls back to today. */
19
+ export function toIsoDate(value: string | undefined): string {
20
+ if (!value) return new Date().toISOString().slice(0, 10);
21
+ const trimmed = value.trim();
22
+ const direct = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
23
+ if (direct) return trimmed;
24
+ const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(trimmed);
25
+ if (german) {
26
+ const [, d, m, y] = german;
27
+ return `${y}-${m!.padStart(2, "0")}-${d!.padStart(2, "0")}`;
28
+ }
29
+ const parsed = new Date(trimmed);
30
+ if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10);
31
+ throw new Error(`Unparseable date: "${value}" (use YYYY-MM-DD)`);
32
+ }
33
+
34
+ /** Accept `8:00`, `08:00`, `0800`; return `HH:MM`. */
35
+ export function toHhMm(value: string): string {
36
+ const trimmed = value.trim();
37
+ const colon = /^(\d{1,2}):(\d{2})$/.exec(trimmed);
38
+ if (colon) return `${colon[1]!.padStart(2, "0")}:${colon[2]}`;
39
+ const compact = /^(\d{2})(\d{2})$/.exec(trimmed);
40
+ if (compact) return `${compact[1]}:${compact[2]}`;
41
+ const hourOnly = /^(\d{1,2})$/.exec(trimmed);
42
+ if (hourOnly) return `${hourOnly[1]!.padStart(2, "0")}:00`;
43
+ throw new Error(`Unparseable time: "${value}" (use HH:MM)`);
44
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * zep_book -- create Projektzeit bookings.
3
+ *
4
+ * This is the replacement for the paid ZEP API: the same HTTP call the web
5
+ * form makes, minus the browser.
6
+ */
7
+
8
+ import { Type } from "typebox";
9
+
10
+ import { createClient } from "../session.ts";
11
+ import { durationBetween } from "../clients/zep-parse.ts";
12
+ import { ZepError } from "../clients/zep-client.ts";
13
+ import { updateProfile } from "../config.ts";
14
+ import type { ZepBookingRequest, ZepFormData, ZepOption } from "../types.ts";
15
+ import { errorResult, textResult, toHhMm, toIsoDate, type ToolResult } from "./shared.ts";
16
+
17
+ interface EntryParam {
18
+ von: string;
19
+ bis: string;
20
+ bemerkung?: string;
21
+ datum?: string;
22
+ projekt?: string;
23
+ vorgang?: string;
24
+ taetigkeit?: string;
25
+ ort?: string;
26
+ }
27
+
28
+ interface BookParams {
29
+ profile?: string;
30
+ datum?: string;
31
+ von?: string;
32
+ bis?: string;
33
+ bemerkung?: string;
34
+ projekt?: string;
35
+ vorgang?: string;
36
+ taetigkeit?: string;
37
+ ort?: string;
38
+ entries?: EntryParam[];
39
+ dryRun?: boolean;
40
+ }
41
+
42
+ /** A project plus the Vorgang/Tätigkeit options ZEP offers for it. */
43
+ interface ProjectScope {
44
+ readonly project: ZepOption | undefined;
45
+ readonly vorgaenge: ReadonlyArray<ZepOption>;
46
+ readonly taetigkeiten: ReadonlyArray<ZepOption>;
47
+ readonly selectedVorgangId: string | null;
48
+ }
49
+
50
+ export const ZepBookTool = {
51
+ name: "zep_book",
52
+ label: "ZEP Book",
53
+ description:
54
+ "Book time in ZEP (Projektzeiten) over plain HTTP. Give a day with 'datum' (default today) and either one von/bis pair or an 'entries' array for several blocks. Project and Vorgang accept an id or part of the label. Use dryRun to see the exact request without saving. ZEP rejects a booking that would exceed the Vorgang's planned hours; such a rejection is reported, never retried silently.",
55
+ parameters: Type.Object({
56
+ profile: Type.Optional(
57
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
58
+ ),
59
+ datum: Type.Optional(
60
+ Type.String({ description: "Day to book (YYYY-MM-DD or DD.MM.YYYY). Defaults to today." }),
61
+ ),
62
+ von: Type.Optional(Type.String({ description: "Start time, e.g. '08:00'." })),
63
+ bis: Type.Optional(Type.String({ description: "End time, e.g. '12:00'." })),
64
+ bemerkung: Type.Optional(
65
+ Type.String({
66
+ description:
67
+ "Comment. Convention: include the ticket/story numbers (e.g. '3447 Ladeanzeige nach Pick, 3455 Fach-HU-Nummer').",
68
+ }),
69
+ ),
70
+ projekt: Type.Optional(
71
+ Type.String({
72
+ description:
73
+ "Project id (e.g. '413' for Julabo) or part of its label (e.g. 'Julabo'). Falls back to the project ZEP has selected for this user.",
74
+ }),
75
+ ),
76
+ vorgang: Type.Optional(
77
+ Type.String({
78
+ description:
79
+ "Vorgang id (e.g. '3284') or part of its label (e.g. '90-01'). Falls back to the Vorgang ZEP has selected for this user.",
80
+ }),
81
+ ),
82
+ taetigkeit: Type.Optional(
83
+ Type.String({
84
+ description: "Activity code, usually 'co' (Consulting), 'co_nb' (non-billable) or 're' (Reisen). Defaults to the last used one.",
85
+ }),
86
+ ),
87
+ ort: Type.Optional(
88
+ Type.String({ description: "Location code, e.g. 'D-Office' or 'D'. Defaults to 'NULL' (first place of work)." }),
89
+ ),
90
+ entries: Type.Optional(
91
+ Type.Array(
92
+ Type.Object({
93
+ von: Type.String({ description: "Start time, e.g. '13:00'." }),
94
+ bis: Type.String({ description: "End time, e.g. '17:00'." }),
95
+ bemerkung: Type.Optional(Type.String({ description: "Comment for this block." })),
96
+ datum: Type.Optional(Type.String({ description: "Overrides the top-level datum." })),
97
+ projekt: Type.Optional(Type.String({ description: "Overrides the top-level projekt." })),
98
+ vorgang: Type.Optional(Type.String({ description: "Overrides the top-level vorgang." })),
99
+ taetigkeit: Type.Optional(Type.String({ description: "Overrides the top-level taetigkeit." })),
100
+ ort: Type.Optional(Type.String({ description: "Overrides the top-level ort." })),
101
+ }),
102
+ { description: "Several blocks in one call, e.g. 08:00-12:00 and 13:00-17:00." },
103
+ ),
104
+ ),
105
+ dryRun: Type.Optional(
106
+ Type.Boolean({
107
+ description: "Resolve everything and show exactly what would be sent, without saving.",
108
+ }),
109
+ ),
110
+ }),
111
+
112
+ async execute(_toolCallId: string, params: BookParams): Promise<ToolResult> {
113
+ try {
114
+ if (!params.entries?.length && (!params.von || !params.bis)) {
115
+ return errorResult("Give 'von' and 'bis', or an 'entries' array.");
116
+ }
117
+
118
+ const handle = await createClient(params.profile);
119
+ const { client, profileName } = handle;
120
+ const { form } = await client.loadForm();
121
+
122
+ const entries: EntryParam[] = params.entries?.length
123
+ ? params.entries
124
+ : [{ von: params.von!, bis: params.bis!, bemerkung: params.bemerkung }];
125
+
126
+ // Which projects does this call touch?
127
+ const wantedRefs = new Set<string>();
128
+ for (const entry of entries) {
129
+ const ref = entry.projekt ?? params.projekt;
130
+ if (ref) wantedRefs.add(ref.trim());
131
+ }
132
+
133
+ const scopes = new Map<string, ProjectScope>();
134
+ const baseProjectId = form.selected.projektId;
135
+
136
+ const scopeFor = async (projectId: string, current: ZepFormData): Promise<ProjectScope> => {
137
+ const cached = scopes.get(projectId);
138
+ if (cached) return cached;
139
+
140
+ // The page only renders Vorgänge for the project it was rendered for.
141
+ let scopeForm = current;
142
+ if (projectId !== current.selected.projektId) {
143
+ const refreshed = await client.refreshFormForProject(projectId);
144
+ if (refreshed.vorgaenge.length) {
145
+ scopeForm = {
146
+ ...current,
147
+ vorgaenge: refreshed.vorgaenge,
148
+ taetigkeiten: refreshed.taetigkeiten.length ? refreshed.taetigkeiten : current.taetigkeiten,
149
+ selected: { ...current.selected, projektId: projectId },
150
+ };
151
+ }
152
+ }
153
+
154
+ const scope: ProjectScope = {
155
+ project: current.projects.find((p) => p.id === projectId),
156
+ vorgaenge: scopeForm.vorgaenge,
157
+ taetigkeiten: scopeForm.taetigkeiten,
158
+ selectedVorgangId:
159
+ scopeForm.selected.projektId === projectId ? scopeForm.selected.vorgangId : null,
160
+ };
161
+ scopes.set(projectId, scope);
162
+ return scope;
163
+ };
164
+
165
+ // Resolve every project reference up front so all Vorgang lists are known.
166
+ for (const ref of wantedRefs) {
167
+ const resolved = matchOption(form.projects, ref);
168
+ if (!resolved) {
169
+ return errorResult(
170
+ `No project matches "${ref}". Known projects: ${form.projects
171
+ .map((p) => `${p.id}=${p.label}`)
172
+ .join(" | ")}`,
173
+ );
174
+ }
175
+ await scopeFor(resolved.id, form);
176
+ }
177
+
178
+ if (baseProjectId && !scopes.has(baseProjectId)) {
179
+ scopes.set(baseProjectId, {
180
+ project: form.projects.find((p) => p.id === baseProjectId),
181
+ vorgaenge: form.vorgaenge,
182
+ taetigkeiten: form.taetigkeiten,
183
+ selectedVorgangId: form.selected.vorgangId,
184
+ });
185
+ }
186
+
187
+ // ------------------------------------------------------------ build plan
188
+ const planned: Array<{ request: ZepBookingRequest; label: string }> = [];
189
+
190
+ for (const entry of entries) {
191
+ const date = toIsoDate(entry.datum ?? params.datum);
192
+ const from = toHhMm(entry.von);
193
+ const to = toHhMm(entry.bis);
194
+ const duration = durationBetween(from, to);
195
+ if (!duration) {
196
+ return errorResult(`Invalid time range ${from}-${to} on ${date}: end must be after start.`);
197
+ }
198
+
199
+ const projektRef = entry.projekt ?? params.projekt;
200
+ const projectId = projektRef
201
+ ? matchOption(form.projects, projektRef)?.id
202
+ : form.selected.projektId;
203
+
204
+ if (!projectId) {
205
+ return errorResult(
206
+ "No project determined. Pass 'projekt' (id or label), or book once manually so ZEP preselects one.",
207
+ );
208
+ }
209
+
210
+ const scope = scopes.get(projectId) ?? (await scopeFor(projectId, form));
211
+
212
+ const vorgangRef = entry.vorgang ?? params.vorgang;
213
+ const vorgangId =
214
+ (vorgangRef ? matchOption(scope.vorgaenge, vorgangRef)?.id : undefined) ??
215
+ scope.selectedVorgangId ??
216
+ (scope.vorgaenge.length === 1 ? scope.vorgaenge[0]!.id : undefined);
217
+
218
+ if (!vorgangId) {
219
+ return errorResult(
220
+ `No Vorgang determined for project ${projectId}. Available: ${scope.vorgaenge
221
+ .map((v) => `${v.id}=${v.label}`)
222
+ .join(" | ") || "(none readable)"}`,
223
+ );
224
+ }
225
+
226
+ const taetigkeit = resolveTaetigkeit(scope, entry.taetigkeit ?? params.taetigkeit);
227
+ const ort = entry.ort ?? params.ort ?? "NULL";
228
+ const comment = entry.bemerkung ?? params.bemerkung ?? "";
229
+
230
+ const vorgangLabel = scope.vorgaenge.find((v) => v.id === vorgangId)?.label;
231
+
232
+ planned.push({
233
+ request: {
234
+ date,
235
+ from,
236
+ to,
237
+ duration,
238
+ projektId: projectId,
239
+ vorgangId,
240
+ taetigkeit,
241
+ comment,
242
+ ort,
243
+ },
244
+ label:
245
+ `${date} ${from}-${to} (${duration})\n` +
246
+ ` project ${projectId}${scope.project ? ` "${scope.project.label}"` : ""}\n` +
247
+ ` Vorgang ${vorgangId}${vorgangLabel ? ` "${vorgangLabel}"` : ""}\n` +
248
+ ` Tätigkeit ${taetigkeit}${ort === "NULL" ? "" : `, Ort ${ort}`}` +
249
+ `${comment ? `\n "${comment}"` : ""}`,
250
+ });
251
+ }
252
+
253
+ if (params.dryRun) {
254
+ return textResult(
255
+ `DRY RUN - nothing saved (session: ${handle.sessionSource}).\n\n${planned
256
+ .map((p) => p.label)
257
+ .join("\n\n")}`,
258
+ { dryRun: true, planned: planned.map((p) => p.request) },
259
+ );
260
+ }
261
+
262
+ // ---------------------------------------------------------------- execute
263
+ const results: string[] = [];
264
+ let failures = 0;
265
+ let firstFailure: string | null = null;
266
+
267
+ for (const item of planned) {
268
+ const result = await client.book(item.request);
269
+ if (result.ok) {
270
+ results.push(`OK ${item.request.date} ${item.request.from}-${item.request.to}`);
271
+ } else {
272
+ failures += 1;
273
+ firstFailure ??= result.error ?? "unknown error";
274
+ results.push(
275
+ `REJECTED ${item.request.date} ${item.request.from}-${item.request.to}\n ${result.error ?? "unknown error"}`,
276
+ );
277
+ }
278
+ }
279
+
280
+ if (failures === 0) {
281
+ updateProfile(profileName, {
282
+ lastProjektId: planned[0]!.request.projektId,
283
+ lastVorgangId: planned[0]!.request.vorgangId,
284
+ lastTaetigkeit: planned[0]!.request.taetigkeit,
285
+ });
286
+ }
287
+
288
+ const summary =
289
+ `${planned.length - failures}/${planned.length} booking(s) saved.` +
290
+ (failures ? `\nFirst error: ${firstFailure}` : "");
291
+
292
+ const text = `${summary}\n\n${results.join("\n")}`;
293
+ return failures ? errorResult(text, { failures }) : textResult(text, { saved: planned.length });
294
+ } catch (error) {
295
+ const message = error instanceof Error ? error.message : String(error);
296
+ if (error instanceof ZepError && error.attemptsLeft !== undefined) {
297
+ return errorResult(`${message} (${error.attemptsLeft} password attempts left)`);
298
+ }
299
+ return errorResult(message);
300
+ }
301
+ },
302
+ };
303
+
304
+ /** Match an id exactly first, then an exact label, then a label substring. */
305
+ function matchOption(
306
+ options: ReadonlyArray<{ id: string; label: string }>,
307
+ reference: string,
308
+ ): { id: string; label: string } | undefined {
309
+ const needle = reference.trim().toLowerCase();
310
+ return (
311
+ options.find((o) => o.id === reference.trim()) ??
312
+ options.find((o) => o.label.toLowerCase() === needle) ??
313
+ options.find((o) => o.label.toLowerCase().includes(needle))
314
+ );
315
+ }
316
+
317
+ function resolveTaetigkeit(scope: ProjectScope, reference: string | undefined): string {
318
+ if (reference) {
319
+ const match = matchOption(scope.taetigkeiten, reference);
320
+ if (match) return match.id;
321
+ }
322
+ return scope.taetigkeiten.find((t) => t.selected)?.id ?? scope.taetigkeiten[0]?.id ?? "co";
323
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * zep_delete -- remove a Projektzeit booking by its row id.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ import { createClient } from "../session.ts";
8
+ import { mondayOf } from "../clients/zep-parse.ts";
9
+ import { errorResult, textResult, toIsoDate, type ToolResult } from "./shared.ts";
10
+
11
+ export const ZepDeleteTool = {
12
+ name: "zep_delete",
13
+ label: "ZEP Delete",
14
+ description:
15
+ "Delete a ZEP Projektzeit booking. Get the row id from zep_week (shown as #<id>). Prefer passing 'datum' so ZEP re-renders the right week.",
16
+ parameters: Type.Object({
17
+ profile: Type.Optional(
18
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
19
+ ),
20
+ objectId: Type.String({ description: "Booking row id from zep_week, e.g. '163533'." }),
21
+ datum: Type.Optional(
22
+ Type.String({ description: "Day the booking sits on (YYYY-MM-DD). Defaults to today." }),
23
+ ),
24
+ }),
25
+
26
+ async execute(
27
+ _toolCallId: string,
28
+ params: { profile?: string; objectId: string; datum?: string },
29
+ ): Promise<ToolResult> {
30
+ try {
31
+ const iso = toIsoDate(params.datum);
32
+ const kwDate = mondayOf(new Date(`${iso}T12:00:00Z`));
33
+ const { client, profileName } = await createClient(params.profile);
34
+ const result = await client.remove(params.objectId, kwDate);
35
+ return result.ok
36
+ ? textResult(`Booking #${params.objectId} deleted.`, { profile: profileName, objectId: params.objectId })
37
+ : errorResult(`Delete of #${params.objectId} failed: ${result.error ?? "unknown error"}`);
38
+ } catch (error) {
39
+ return errorResult(error instanceof Error ? error.message : String(error));
40
+ }
41
+ },
42
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * zep_login -- establish a ZEP session.
3
+ *
4
+ * ZEP does not offer a token exchange: a session is a `PHPSESSID` cookie
5
+ * bound 1:1 to a `CLIENTSESSID` query parameter, and both are only issued by
6
+ * the login POST. That is why this tool exists separately from zep_setup:
7
+ * zep_setup stores the credentials, zep_login proves they work and caches the
8
+ * resulting session.
9
+ */
10
+
11
+ import { Type } from "typebox";
12
+
13
+ import { getActiveProfileName, getProfile } from "../config.ts";
14
+ import { createClient, loginProfile } from "../session.ts";
15
+ import { errorResult, textResult, type ToolResult } from "./shared.ts";
16
+
17
+ export const ZepLoginTool = {
18
+ name: "zep_login",
19
+ label: "ZEP Login",
20
+ description:
21
+ "Log in to ZEP with the stored credentials and cache the session in ~/.pi/zep-session.json. Use force to ignore a cached session. Reports the remaining password attempts when the login fails (ZEP locks the account after 5).",
22
+ parameters: Type.Object({
23
+ profile: Type.Optional(
24
+ Type.String({ description: "ZEP profile to use. Defaults to the active profile." }),
25
+ ),
26
+ force: Type.Optional(
27
+ Type.Boolean({
28
+ description: "Ignore a cached session and log in from scratch.",
29
+ }),
30
+ ),
31
+ }),
32
+
33
+ async execute(
34
+ _toolCallId: string,
35
+ params: { profile?: string; force?: boolean },
36
+ ): Promise<ToolResult> {
37
+ try {
38
+ const name = params.profile ?? getActiveProfileName();
39
+ if (!name) return errorResult("No ZEP profile configured. Call zep_setup first.");
40
+ const account = getProfile(name);
41
+
42
+ if (!params.force) {
43
+ const handle = await createClient(params.profile);
44
+ if (handle.sessionSource === "cache") {
45
+ return textResult(
46
+ `Cached session for "${name}" is still valid - no login needed.\n` +
47
+ `CLIENTSESSID: ${handle.client.sessionId}\n` +
48
+ `Request token: ${handle.client.token}\n` +
49
+ `(use force=true to log in again)`,
50
+ { profile: name, sessionSource: "cache" },
51
+ );
52
+ }
53
+ return textResult(
54
+ `Logged in to ZEP as "${account.userid}" (profile "${name}").\n` +
55
+ `URL: ${account.baseUrl}/${account.mandant}\n` +
56
+ `CLIENTSESSID: ${handle.client.sessionId}\n` +
57
+ `Request token: ${handle.client.token}`,
58
+ { profile: name, clientsessid: handle.client.sessionId, sessionSource: "fresh" },
59
+ );
60
+ }
61
+
62
+ const client = await loginProfile(name);
63
+ return textResult(
64
+ `Logged in to ZEP as "${account.userid}" (profile "${name}").\n` +
65
+ `URL: ${account.baseUrl}/${account.mandant}\n` +
66
+ `CLIENTSESSID: ${client.sessionId}\n` +
67
+ `Request token: ${client.token}`,
68
+ { profile: name, clientsessid: client.sessionId, sessionSource: "fresh" },
69
+ );
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ return errorResult(message);
73
+ }
74
+ },
75
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * zep_profile -- list, switch or delete ZEP profiles.
3
+ */
4
+
5
+ import { Type } from "typebox";
6
+
7
+ import {
8
+ clearSession,
9
+ deleteProfile,
10
+ getActiveProfileName,
11
+ listProfiles,
12
+ useProfile,
13
+ } from "../config.ts";
14
+ import { errorResult, textResult, type ToolResult } from "./shared.ts";
15
+
16
+ export const ZepProfileTool = {
17
+ name: "zep_profile",
18
+ label: "ZEP Profile",
19
+ description:
20
+ "List configured ZEP profiles, switch the active one, or delete a profile. Without arguments it lists all profiles.",
21
+ parameters: Type.Object({
22
+ action: Type.Optional(
23
+ Type.String({ description: "One of: list, use, delete. Defaults to list." }),
24
+ ),
25
+ name: Type.Optional(
26
+ Type.String({ description: "Profile name for 'use' and 'delete'." }),
27
+ ),
28
+ }),
29
+
30
+ async execute(
31
+ _toolCallId: string,
32
+ params: { action?: string; name?: string },
33
+ ): Promise<ToolResult> {
34
+ const action = params.action ?? "list";
35
+ try {
36
+ if (action === "list") {
37
+ const profiles = listProfiles();
38
+ if (profiles.length === 0) {
39
+ return textResult("No ZEP profiles configured. Call zep_setup first.", { profiles: [] });
40
+ }
41
+ const active = getActiveProfileName();
42
+ const lines = profiles.map((p) => `${p === active ? "*" : " "} ${p}`).join("\n");
43
+ return textResult(`ZEP profiles (${profiles.length})\n${lines}`, { profiles, active });
44
+ }
45
+
46
+ if (!params.name) return errorResult(`Action "${action}" needs a profile name.`);
47
+
48
+ if (action === "use") {
49
+ useProfile(params.name);
50
+ return textResult(`Active ZEP profile is now "${params.name}".`, { active: params.name });
51
+ }
52
+
53
+ if (action === "delete") {
54
+ deleteProfile(params.name);
55
+ return textResult(
56
+ `ZEP profile "${params.name}" deleted (including its cached session).`,
57
+ { deleted: params.name },
58
+ );
59
+ }
60
+
61
+ if (action === "clear-session") {
62
+ clearSession(params.name);
63
+ return textResult(`Cached ZEP session for "${params.name}" cleared.`, {
64
+ cleared: params.name,
65
+ });
66
+ }
67
+
68
+ return errorResult(`Unknown action "${action}". Use list, use, delete or clear-session.`);
69
+ } catch (error) {
70
+ return errorResult(error instanceof Error ? error.message : String(error));
71
+ }
72
+ },
73
+ };