@timqi/pier 0.1.4 → 0.1.6
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/README.md +4 -4
- package/dist/agent/events.js +12 -3
- package/dist/agent/listing.js +10 -2
- package/dist/agent/packages.js +25 -14
- package/dist/agent/pi.js +13 -40
- package/dist/channels/attach.js +2 -2
- package/dist/channels/commands.js +10 -8
- package/dist/channels/config.js +4 -6
- package/dist/channels/control.js +60 -14
- package/dist/channels/conversations.js +60 -13
- package/dist/channels/handoff.js +90 -0
- package/dist/channels/lark-api.js +7 -0
- package/dist/channels/lark-outbound.js +19 -0
- package/dist/channels/lark-panel.js +41 -26
- package/dist/channels/lark-render.js +2 -1
- package/dist/channels/lark.js +35 -26
- package/dist/channels/lines.js +1 -1
- package/dist/channels/panel.js +324 -115
- package/dist/channels/receipts.js +25 -12
- package/dist/channels/routes.js +21 -4
- package/dist/channels/runtime.js +21 -7
- package/dist/channels/slack-outbound.js +10 -0
- package/dist/channels/slack-panel.js +81 -40
- package/dist/channels/slack-render.js +3 -1
- package/dist/channels/slack.js +29 -14
- package/dist/channels/types.js +1 -2
- package/dist/cli.js +13 -5
- package/dist/core/identity.js +47 -12
- package/dist/core/reply.js +4 -1
- package/dist/core/router.js +20 -3
- package/dist/db.js +6 -1
- package/dist/main.js +42 -9
- package/dist/service.js +4 -2
- package/dist/settings.js +3 -12
- package/dist/socket.js +10 -6
- package/dist/tasks/cli.js +17 -7
- package/dist/tasks/operations.js +24 -12
- package/dist/tasks/routes.js +1 -0
- package/dist/tools.js +1 -3
- package/dist/web/public/assets/activity-BWpiRicQ.js +5 -0
- package/dist/web/public/assets/activity-BWpiRicQ.js.br +0 -0
- package/dist/web/public/assets/activity-BWpiRicQ.js.gz +0 -0
- package/dist/web/public/assets/boards-BnRMjBln.js +1 -0
- package/dist/web/public/assets/boards-BnRMjBln.js.br +0 -0
- package/dist/web/public/assets/boards-BnRMjBln.js.gz +0 -0
- package/dist/web/public/assets/explorer-DLZ0ccgE.js +4 -0
- package/dist/web/public/assets/explorer-DLZ0ccgE.js.br +0 -0
- package/dist/web/public/assets/explorer-DLZ0ccgE.js.gz +0 -0
- package/dist/web/public/assets/index-DMq4awU8.css +2 -0
- package/dist/web/public/assets/index-DMq4awU8.css.br +0 -0
- package/dist/web/public/assets/index-DMq4awU8.css.gz +0 -0
- package/dist/web/public/assets/index-T-vLXPch.js +85 -0
- package/dist/web/public/assets/index-T-vLXPch.js.br +0 -0
- package/dist/web/public/assets/index-T-vLXPch.js.gz +0 -0
- package/dist/web/public/assets/runs-oFQ162um.js +1 -0
- package/dist/web/public/assets/runs-oFQ162um.js.br +0 -0
- package/dist/web/public/assets/runs-oFQ162um.js.gz +0 -0
- package/dist/web/public/assets/settings-_t0Js-DJ.js +5 -0
- package/dist/web/public/assets/settings-_t0Js-DJ.js.br +0 -0
- package/dist/web/public/assets/settings-_t0Js-DJ.js.gz +0 -0
- package/dist/web/public/assets/task-runs-m3ufvOQI.js +3 -0
- package/dist/web/public/assets/task-runs-m3ufvOQI.js.br +0 -0
- package/dist/web/public/assets/task-runs-m3ufvOQI.js.gz +0 -0
- package/dist/web/public/assets/tasks-C1ATvPUc.js +4 -0
- package/dist/web/public/assets/tasks-C1ATvPUc.js.br +0 -0
- package/dist/web/public/assets/tasks-C1ATvPUc.js.gz +0 -0
- package/dist/web/public/index.html +3 -3
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/push.js +4 -10
- package/dist/web/server.js +5 -4
- package/dist/{extensions/web → websearch}/artifacts.js +2 -2
- package/dist/websearch/cli.js +73 -0
- package/dist/websearch/run.js +273 -0
- package/docs/deploy.md +4 -4
- package/package.json +2 -3
- package/skills/pier-help/SKILL.md +22 -10
- package/skills/pier-tasks/SKILL.md +17 -8
- package/skills/pier-web/SKILL.md +50 -0
- package/dist/channels/telegram-api.js +0 -86
- package/dist/channels/telegram-panel.js +0 -97
- package/dist/channels/telegram-render.js +0 -69
- package/dist/channels/telegram.js +0 -421
- package/dist/extensions/index.js +0 -10
- package/dist/extensions/web/index.js +0 -9
- package/dist/extensions/web/tools.js +0 -265
- package/dist/web/public/assets/activity-NLS8W9yl.js +0 -5
- package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
- package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js +0 -1
- package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js +0 -4
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css +0 -2
- package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js +0 -85
- package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js +0 -1
- package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js +0 -5
- package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +0 -3
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js +0 -4
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
- /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
- /package/dist/{extensions/web → websearch}/content.js +0 -0
- /package/dist/{extensions/web → websearch}/http.js +0 -0
- /package/dist/{extensions/web → websearch}/json.js +0 -0
- /package/dist/{extensions/web → websearch}/language.js +0 -0
- /package/dist/{extensions/web → websearch}/openai.js +0 -0
- /package/dist/{extensions/web → websearch}/provider.js +0 -0
package/dist/channels/routes.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// Settings → Channels HTTP surface: one document per platform
|
|
1
|
+
// Settings → Channels HTTP surface: one document per platform, and the
|
|
2
|
+
// web → IM handoff beside it.
|
|
2
3
|
import { isThinkingLevel } from "../core/types.js";
|
|
4
|
+
import { HandoffError } from "./handoff.js";
|
|
3
5
|
import { defaultChannelConfig, isChannelPlatform, } from "./types.js";
|
|
4
6
|
/** Stable mask: recomputable at write time, so "unchanged" is detectable. */
|
|
5
7
|
const maskToken = (token) => token ? `${"•".repeat(8)}${token.slice(-4)}` : "";
|
|
@@ -35,14 +37,13 @@ function parseChats(raw, known) {
|
|
|
35
37
|
enabled: asBool(edit.enabled),
|
|
36
38
|
requireMention: asBool(edit.requireMention),
|
|
37
39
|
requireBind: asBool(edit.requireBind),
|
|
38
|
-
topicMode: asBool(edit.topicMode),
|
|
39
40
|
cwd: asString(edit.cwd),
|
|
40
41
|
model: asModel(edit.model),
|
|
41
42
|
thinking: asThinking(edit.thinking),
|
|
42
43
|
};
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
|
-
export function registerChannelRoutes(app, store, runtime) {
|
|
46
|
+
export function registerChannelRoutes(app, store, runtime, handoff) {
|
|
46
47
|
app.get("/api/channels/:platform", (c) => {
|
|
47
48
|
const platform = c.req.param("platform");
|
|
48
49
|
if (!isChannelPlatform(platform))
|
|
@@ -72,7 +73,6 @@ export function registerChannelRoutes(app, store, runtime) {
|
|
|
72
73
|
appToken: kept(asString(body.appToken), current.appToken),
|
|
73
74
|
requireMention: asBool(body.requireMention),
|
|
74
75
|
requireBind: asBool(body.requireBind),
|
|
75
|
-
topicMode: asBool(body.topicMode),
|
|
76
76
|
cwd: asString(body.cwd),
|
|
77
77
|
model: asModel(body.model),
|
|
78
78
|
thinking: asThinking(body.thinking),
|
|
@@ -97,4 +97,21 @@ export function registerChannelRoutes(app, store, runtime) {
|
|
|
97
97
|
store.unbind(platform, c.req.param("id"));
|
|
98
98
|
return c.json({ ok: true });
|
|
99
99
|
});
|
|
100
|
+
app.get("/api/handoff/targets", (c) => c.json({ targets: handoff.targets() }));
|
|
101
|
+
app.post("/api/handoff", async (c) => {
|
|
102
|
+
const body = (await c.req.json().catch(() => null));
|
|
103
|
+
const sessionId = asString(body?.sessionId);
|
|
104
|
+
const chatId = asString(body?.chatId);
|
|
105
|
+
const platform = body?.platform;
|
|
106
|
+
if (!sessionId || !chatId || !isChannelPlatform(platform))
|
|
107
|
+
return c.json({ error: "invalid body" }, 400);
|
|
108
|
+
try {
|
|
109
|
+
return c.json(await handoff.continueIn({ sessionId, platform, chatId }), 201);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof HandoffError)
|
|
113
|
+
return c.json({ error: err.message }, err.status);
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
100
117
|
}
|
package/dist/channels/runtime.js
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
import { logger } from "../log.js";
|
|
4
4
|
import { LarkChannel } from "./lark.js";
|
|
5
5
|
import { SlackChannel } from "./slack.js";
|
|
6
|
-
import { TelegramChannel } from "./telegram.js";
|
|
7
6
|
const ADAPTERS = [
|
|
8
|
-
{ platform: "
|
|
9
|
-
{ platform: "slack", needsAppToken: true, build: (deps) => new SlackChannel(deps) },
|
|
7
|
+
{ platform: "slack", build: (deps) => new SlackChannel(deps) },
|
|
10
8
|
// Lark's "token" is the App ID and "appToken" the App Secret.
|
|
11
|
-
{ platform: "lark",
|
|
9
|
+
{ platform: "lark", build: (deps) => new LarkChannel(deps) },
|
|
12
10
|
];
|
|
13
11
|
// The injected sink is for warnings; "slack started" is not one.
|
|
14
12
|
const log = logger("channels");
|
|
@@ -18,14 +16,18 @@ export class ChannelRuntime {
|
|
|
18
16
|
store;
|
|
19
17
|
router;
|
|
20
18
|
control;
|
|
19
|
+
handoff;
|
|
21
20
|
log;
|
|
22
21
|
live = new Map();
|
|
23
22
|
reloading = Promise.resolve();
|
|
24
23
|
stopped = false;
|
|
25
|
-
constructor(store, router, control,
|
|
24
|
+
constructor(store, router, control,
|
|
25
|
+
/** The pull half only; the push half needs this runtime, so main.ts closes the loop. */
|
|
26
|
+
handoff, log = warn) {
|
|
26
27
|
this.store = store;
|
|
27
28
|
this.router = router;
|
|
28
29
|
this.control = control;
|
|
30
|
+
this.handoff = handoff;
|
|
29
31
|
this.log = log;
|
|
30
32
|
}
|
|
31
33
|
/** Serialized: two concurrent Console saves would race into duplicate live
|
|
@@ -45,7 +47,7 @@ export class ChannelRuntime {
|
|
|
45
47
|
return run;
|
|
46
48
|
}
|
|
47
49
|
async restart(adapter) {
|
|
48
|
-
const { platform,
|
|
50
|
+
const { platform, build } = adapter;
|
|
49
51
|
const existing = this.live.get(platform);
|
|
50
52
|
if (existing) {
|
|
51
53
|
this.live.delete(platform);
|
|
@@ -55,7 +57,7 @@ export class ChannelRuntime {
|
|
|
55
57
|
const config = this.store.get(platform);
|
|
56
58
|
if (!config.enabled || !config.token)
|
|
57
59
|
return;
|
|
58
|
-
if (
|
|
60
|
+
if (!config.appToken) {
|
|
59
61
|
// "Enabled but nothing happens" is indistinguishable from a broken adapter.
|
|
60
62
|
this.log(`${platform}: enabled but no app token, not starting`);
|
|
61
63
|
return;
|
|
@@ -64,6 +66,7 @@ export class ChannelRuntime {
|
|
|
64
66
|
store: this.store,
|
|
65
67
|
log: (m) => this.log(`${platform}: ${m}`),
|
|
66
68
|
control: this.control,
|
|
69
|
+
handoff: this.handoff,
|
|
67
70
|
});
|
|
68
71
|
try {
|
|
69
72
|
await channel.start((msg) => {
|
|
@@ -87,6 +90,17 @@ export class ChannelRuntime {
|
|
|
87
90
|
await channel.notify(conversationId, { text, origin: { kind: "error" } });
|
|
88
91
|
return true;
|
|
89
92
|
}
|
|
93
|
+
running() {
|
|
94
|
+
return [...this.live.keys()];
|
|
95
|
+
}
|
|
96
|
+
/** Throws by name when the platform is not running: the caller's answer is
|
|
97
|
+
* "enable it in Settings", not a silent no-op. */
|
|
98
|
+
async openThread(platform, chatId, note) {
|
|
99
|
+
const channel = this.live.get(platform);
|
|
100
|
+
if (!channel)
|
|
101
|
+
throw new Error(`${platform} is not running`);
|
|
102
|
+
return channel.openThread(chatId, note);
|
|
103
|
+
}
|
|
90
104
|
async stop() {
|
|
91
105
|
// An in-flight restart could otherwise register an adapter after `live`
|
|
92
106
|
// was cleared — running, unstoppable.
|
|
@@ -50,6 +50,16 @@ export class SlackOutbound {
|
|
|
50
50
|
}
|
|
51
51
|
return ts;
|
|
52
52
|
}
|
|
53
|
+
/** The one root Pier posts (no `thread_ts`): the thread a web session is
|
|
54
|
+
* continued in has no user message to hang from. Answers the root's `ts`. */
|
|
55
|
+
async open(channel, note) {
|
|
56
|
+
const link = note.url || "_(no public URL set — Settings → Instance)_";
|
|
57
|
+
const sent = await this.api.postMessage({
|
|
58
|
+
channel,
|
|
59
|
+
text: `Continued from web: *${escapeMrkdwn(note.title)}*\n${link}\n_Reply in this thread to continue._`,
|
|
60
|
+
});
|
|
61
|
+
return sent.ts;
|
|
62
|
+
}
|
|
53
63
|
budget() {
|
|
54
64
|
return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
|
|
55
65
|
}
|
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
// Slack's half of the settings panel (panel.ts has the rest).
|
|
2
|
-
// `
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import {
|
|
1
|
+
// Slack's half of the settings panel (panel.ts has the rest). Every button's
|
|
2
|
+
// `value` and the modal's `private_metadata` carry the draft with the card's
|
|
3
|
+
// coordinates, so a click or a submission after a reload is understood from
|
|
4
|
+
// the payload alone and lands on the message the user is looking at.
|
|
5
|
+
import { ChatPanel, CWD_DRAFT_TAIL, CWD_PLACEHOLDER, holdQuestion, PANEL_PREFIX, readDraft, serializeDraft, } from "./panel.js";
|
|
6
|
+
import { context, escapeMrkdwn as esc, section, truncate } from "./slack-render.js";
|
|
7
|
+
import { chatOf } from "./types.js";
|
|
6
8
|
const CWD_VIEW = "cfg_cwd";
|
|
7
9
|
const CWD_BLOCK = "cwd_block";
|
|
8
10
|
const CWD_INPUT = "cwd_input";
|
|
9
|
-
const button = (b) => ({
|
|
11
|
+
const button = (b, value) => ({
|
|
10
12
|
type: "button",
|
|
11
13
|
action_id: `${PANEL_PREFIX}${b.action}`,
|
|
12
|
-
text: { type: "plain_text", text: b.label, emoji: true },
|
|
14
|
+
text: { type: "plain_text", text: truncate(b.label), emoji: true },
|
|
15
|
+
...(value ? { value } : {}),
|
|
13
16
|
});
|
|
14
|
-
const row = (buttons) => ({
|
|
17
|
+
const row = (buttons, value) => ({
|
|
15
18
|
type: "actions",
|
|
16
|
-
elements: buttons.map(button),
|
|
19
|
+
elements: buttons.map((b) => button(b, value)),
|
|
17
20
|
});
|
|
21
|
+
const fresh = (channel, ts, draft) => ({ chatId: channel, ts, draft, dirs: [], sessions: [] });
|
|
18
22
|
export class SlackPanel extends ChatPanel {
|
|
19
23
|
deps;
|
|
20
|
-
platform = "slack";
|
|
21
24
|
fence = ["`", "`"];
|
|
22
25
|
constructor(deps) {
|
|
23
26
|
super(deps);
|
|
@@ -26,30 +29,64 @@ export class SlackPanel extends ChatPanel {
|
|
|
26
29
|
esc(text) {
|
|
27
30
|
return esc(text);
|
|
28
31
|
}
|
|
32
|
+
/** Said on the next redraw when a value could not be read: the picks it held are gone. */
|
|
33
|
+
resetNote;
|
|
34
|
+
/** A malformed value is a stale or foreign card: logged, drawn as an empty draft, and said. */
|
|
35
|
+
parseDraft(raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return {};
|
|
38
|
+
try {
|
|
39
|
+
return readDraft(JSON.parse(raw));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
this.deps.log(`unreadable panel value, draft reset: ${String(err)}`);
|
|
43
|
+
this.resetNote = "Your earlier picks could not be read — pick again.";
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
29
47
|
// --- rendering -------------------------------------------------------------
|
|
30
|
-
blocks(view, note) {
|
|
48
|
+
blocks(view, draft, note) {
|
|
49
|
+
const value = serializeDraft(draft);
|
|
31
50
|
return [
|
|
32
51
|
...view.groups.map((g) => section([`*${g.title}*${g.suffix ?? ""}`, ...g.lines].join("\n"))),
|
|
33
|
-
...(view.picks?.length ? [row(view.picks)] : []),
|
|
34
|
-
...view.rows.filter((r) => r.length).map(row),
|
|
52
|
+
...(view.picks?.length ? [row(view.picks, value)] : []),
|
|
53
|
+
...view.rows.filter((r) => r.length).map((r) => row(r, value)),
|
|
35
54
|
...(note ? [context(esc(note))] : []),
|
|
36
55
|
];
|
|
37
56
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
/** A card that cannot be posted must not look like nothing happening: the
|
|
58
|
+
* thread gets the reason as plain text. */
|
|
59
|
+
async open(key, channel, threadTs, question) {
|
|
60
|
+
const draft = holdQuestion(question);
|
|
61
|
+
const state = fresh(channel, "", draft);
|
|
62
|
+
let sent;
|
|
63
|
+
try {
|
|
64
|
+
sent = await this.deps.api.postMessage({
|
|
65
|
+
channel,
|
|
66
|
+
thread_ts: threadTs,
|
|
67
|
+
text: "Settings",
|
|
68
|
+
blocks: this.blocks(await this.view(key, state), draft),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
this.deps.log(`panel open failed: ${String(err)}`);
|
|
73
|
+
await this.deps.api.postMessage({
|
|
74
|
+
channel,
|
|
75
|
+
thread_ts: threadTs,
|
|
76
|
+
text: `Could not open the panel: ${String(err)}`,
|
|
77
|
+
}).catch((e) => this.deps.log(`panel open failure not posted: ${String(e)}`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.remember(key, { ...state, ts: sent.ts });
|
|
46
81
|
}
|
|
47
82
|
async draw(state, view, note) {
|
|
83
|
+
const said = note ?? this.resetNote;
|
|
84
|
+
this.resetNote = undefined;
|
|
48
85
|
await this.deps.api.updateMessage({
|
|
49
86
|
channel: state.chatId,
|
|
50
87
|
ts: state.ts,
|
|
51
88
|
text: "Settings",
|
|
52
|
-
blocks: this.blocks(view,
|
|
89
|
+
blocks: this.blocks(view, state.draft, said),
|
|
53
90
|
}).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
|
|
54
91
|
}
|
|
55
92
|
async erase(state) {
|
|
@@ -57,35 +94,32 @@ export class SlackPanel extends ChatPanel {
|
|
|
57
94
|
.catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
|
|
58
95
|
}
|
|
59
96
|
// --- actions ---------------------------------------------------------------
|
|
60
|
-
/** Returns false when the action is not ours.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const message = interaction.message;
|
|
65
|
-
if (channel && message)
|
|
66
|
-
await this.open(key, channel, message.thread_ts ?? message.ts);
|
|
67
|
-
});
|
|
97
|
+
/** Returns false when the action is not ours. `run` delivers Start's
|
|
98
|
+
* question as the clicker's message. */
|
|
99
|
+
async onAction(interaction, key, actionId, run) {
|
|
100
|
+
return this.dispatch(key, actionId, interaction, () => fresh(interaction.channel?.id ?? chatOf(key.conversationId), interaction.message?.ts ?? "", this.parseDraft(interaction.actions?.[0]?.value)), run);
|
|
68
101
|
}
|
|
69
102
|
// --- working directory (one typed answer, in a modal) ----------------------
|
|
70
|
-
async promptCwd(key,
|
|
103
|
+
async promptCwd(key, state, interaction) {
|
|
71
104
|
const trigger = interaction.trigger_id;
|
|
72
105
|
if (!trigger) {
|
|
73
106
|
await this.refresh(key, "Could not open the dialog — try again.");
|
|
74
107
|
return;
|
|
75
108
|
}
|
|
109
|
+
const metadata = { conversation: key.conversationId, ts: state.ts, draft: serializeDraft(state.draft) };
|
|
76
110
|
await this.deps.api.openView(trigger, {
|
|
77
111
|
type: "modal",
|
|
78
112
|
callback_id: CWD_VIEW,
|
|
79
|
-
private_metadata:
|
|
80
|
-
title: { type: "plain_text", text: "
|
|
81
|
-
submit: { type: "plain_text", text: "
|
|
113
|
+
private_metadata: JSON.stringify(metadata),
|
|
114
|
+
title: { type: "plain_text", text: "Directory" },
|
|
115
|
+
submit: { type: "plain_text", text: "Set" },
|
|
82
116
|
close: { type: "plain_text", text: "Cancel" },
|
|
83
117
|
blocks: [
|
|
84
118
|
{
|
|
85
119
|
type: "input",
|
|
86
120
|
block_id: CWD_BLOCK,
|
|
87
121
|
label: { type: "plain_text", text: "Working directory" },
|
|
88
|
-
hint: { type: "plain_text", text: `An absolute path. ${
|
|
122
|
+
hint: { type: "plain_text", text: `An absolute path. ${CWD_DRAFT_TAIL}` },
|
|
89
123
|
element: {
|
|
90
124
|
type: "plain_text_input",
|
|
91
125
|
action_id: CWD_INPUT,
|
|
@@ -100,11 +134,18 @@ export class SlackPanel extends ChatPanel {
|
|
|
100
134
|
const view = interaction.view;
|
|
101
135
|
if (view?.callback_id !== CWD_VIEW)
|
|
102
136
|
return false;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
137
|
+
let meta;
|
|
138
|
+
try {
|
|
139
|
+
meta = JSON.parse(view.private_metadata ?? "");
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.deps.log(`cwd modal without readable metadata, dropped: ${String(err)}`);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const key = { channelId: "slack", conversationId: meta.conversation };
|
|
146
|
+
if (!this.state(key))
|
|
147
|
+
this.remember(key, fresh(chatOf(meta.conversation), meta.ts, this.parseDraft(meta.draft)));
|
|
148
|
+
await this.chooseDir(key, (view.state?.values?.[CWD_BLOCK]?.[CWD_INPUT]?.value ?? "").trim());
|
|
108
149
|
return true;
|
|
109
150
|
}
|
|
110
151
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// `actions` row. mrkdwn is not markdown (`*bold*`, `_italic_`, `~strike~`,
|
|
3
3
|
// `<url|label>`), so the agent's markdown is translated; Slack degrades unknown
|
|
4
4
|
// syntax to literal text rather than rejecting the message.
|
|
5
|
+
import { cut } from "../core/reply.js";
|
|
5
6
|
import { balanceFences, chunkText } from "./chunk.js";
|
|
6
7
|
/** Slack caps `markdown` blocks at 12,000 cumulative chars per message. */
|
|
7
8
|
export const MARKDOWN_MAX = 11_000;
|
|
@@ -97,7 +98,8 @@ export const context = (text) => ({
|
|
|
97
98
|
/** `action_id` carries an index: the label is read back off the message Slack
|
|
98
99
|
* echoes with the click, so a button survives a reload. */
|
|
99
100
|
export const OFFER_PREFIX = "sg:";
|
|
100
|
-
|
|
101
|
+
/** Slack refuses a button label over BUTTON_MAX: every button, the panel's too. */
|
|
102
|
+
export const truncate = (label) => cut(label, BUTTON_MAX);
|
|
101
103
|
/** Slack wraps buttons on its own, so there is no row packing to budget. */
|
|
102
104
|
export function actions(labels) {
|
|
103
105
|
if (!labels.length)
|
package/dist/channels/slack.js
CHANGED
|
@@ -10,7 +10,7 @@ import { awaitsTurn } from "../core/reply.js";
|
|
|
10
10
|
import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
|
|
11
11
|
import { logger } from "../log.js";
|
|
12
12
|
import { Chains } from "./chains.js";
|
|
13
|
-
import { parseCommand } from "./commands.js";
|
|
13
|
+
import { parseCommand, settingsDraft } from "./commands.js";
|
|
14
14
|
import { Dedup } from "./dedup.js";
|
|
15
15
|
import { Gatekeeper } from "./gatekeeper.js";
|
|
16
16
|
import { ReceiptLedger, Receipts } from "./receipts.js";
|
|
@@ -24,7 +24,8 @@ const WORKING = "eyes";
|
|
|
24
24
|
// The envelope is already acked, so this bounds concurrency (sockets,
|
|
25
25
|
// downloads), not the backlog.
|
|
26
26
|
const MAX_ACTIVE_CHATS = 16;
|
|
27
|
-
|
|
27
|
+
/** Only an idle conversation ages out, so this need not cover a long turn. */
|
|
28
|
+
const RECEIPT_STALE_MS = 10 * 60_000;
|
|
28
29
|
const DRAIN_TIMEOUT_MS = 5000;
|
|
29
30
|
/** Only has to cover a redelivery that crossed our immediate ack. */
|
|
30
31
|
const DEDUP_TTL_MS = 5 * 60_000;
|
|
@@ -101,12 +102,12 @@ export class SlackChannel {
|
|
|
101
102
|
setReaction: (channel, ts, emoji) => emoji
|
|
102
103
|
? this.api.addReaction(channel, ts, emoji)
|
|
103
104
|
: this.api.removeReaction(channel, ts, WORKING),
|
|
104
|
-
}, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS);
|
|
105
|
-
if (deps.control) {
|
|
105
|
+
}, deps.receipts ?? new ReceiptLedger("slack"), this.log, WORKING, RECEIPT_STALE_MS, (conversationId) => deps.control?.working({ channelId: this.id, conversationId }) ?? false);
|
|
106
|
+
if (deps.control && deps.handoff) {
|
|
106
107
|
this.panel = new SlackPanel({
|
|
107
108
|
api: this.api,
|
|
108
109
|
control: deps.control,
|
|
109
|
-
|
|
110
|
+
handoff: deps.handoff,
|
|
110
111
|
log: this.log,
|
|
111
112
|
});
|
|
112
113
|
}
|
|
@@ -218,14 +219,17 @@ export class SlackChannel {
|
|
|
218
219
|
return this.bind(channel, event.user, threadTs, command?.args ?? "");
|
|
219
220
|
if (command?.name === "stop")
|
|
220
221
|
return this.abortTurn(here, channel, threadTs);
|
|
221
|
-
// A bare `@bot` and `settings` are the same request.
|
|
222
|
-
if (this.panel && (command?.name === "settings" || (!text && !files.length && !shares.length))) {
|
|
223
|
-
return this.panel.open(here, channel, threadTs);
|
|
224
|
-
}
|
|
225
222
|
// Downloading only past the gate: an unauthorized sender must not make the
|
|
226
223
|
// bot pull bytes on their behalf.
|
|
227
224
|
const markers = await this.saveAttachments(files);
|
|
228
225
|
const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
|
|
226
|
+
// A bare `@bot` and `settings` are the same request; `s <text>` drafts a
|
|
227
|
+
// session, so only where this message would start one: a thread root. The
|
|
228
|
+
// held question carries its markers, so Start sends what the user sent.
|
|
229
|
+
const question = threadTs === ts ? settingsDraft(text) : undefined;
|
|
230
|
+
if (this.panel && (question || command?.name === "settings" || (!text && !files.length && !shares.length))) {
|
|
231
|
+
return this.panel.open(here, channel, threadTs, question && [question, ...shared, ...markers].join("\n"));
|
|
232
|
+
}
|
|
229
233
|
// Resolved before the mark: any await between mark() and dispatch is a
|
|
230
234
|
// window in which a previous turn can settle and take this receipt with it.
|
|
231
235
|
const sender = { id: event.user, name: await this.directory.user(this.api, event.user) };
|
|
@@ -272,7 +276,9 @@ export class SlackChannel {
|
|
|
272
276
|
});
|
|
273
277
|
if (!admitted)
|
|
274
278
|
return;
|
|
275
|
-
|
|
279
|
+
// Start's question: the card is the message the click was on, so it carries the 👀.
|
|
280
|
+
const run = (text) => this.deliver(key, channel, message.ts, user, text, onMessage);
|
|
281
|
+
if (await this.panel?.onAction(interaction, key, actionId, run))
|
|
276
282
|
return;
|
|
277
283
|
const text = offeredLabel(message.blocks, actionId);
|
|
278
284
|
if (text === undefined) {
|
|
@@ -285,7 +291,6 @@ export class SlackChannel {
|
|
|
285
291
|
await this.retireOptions(channel, message.ts, message.blocks);
|
|
286
292
|
// A bot cannot post as the user, so the pick is echoed: otherwise the
|
|
287
293
|
// thread shows an answer to a request nobody can see, with nothing to carry the eyes.
|
|
288
|
-
const sender = { id: user, name: await this.directory.user(this.api, user) };
|
|
289
294
|
const echo = await this.api.postMessage({
|
|
290
295
|
channel,
|
|
291
296
|
thread_ts: threadTs,
|
|
@@ -294,9 +299,14 @@ export class SlackChannel {
|
|
|
294
299
|
this.log(`option echo failed: ${String(err)}`);
|
|
295
300
|
return undefined;
|
|
296
301
|
});
|
|
302
|
+
await this.deliver(key, channel, echo?.ts, user, text, onMessage);
|
|
303
|
+
}
|
|
304
|
+
/** A click's text as the clicker's message; `ts` is the message that carries the 👀. */
|
|
305
|
+
async deliver(key, channel, ts, user, text, onMessage) {
|
|
306
|
+
const sender = { id: user, name: await this.directory.user(this.api, user) };
|
|
297
307
|
// No await between mark and dispatch — see onMessage.
|
|
298
|
-
if (
|
|
299
|
-
this.receipts.mark(key.conversationId, channel,
|
|
308
|
+
if (ts)
|
|
309
|
+
this.receipts.mark(key.conversationId, channel, ts);
|
|
300
310
|
onMessage({ key, senderId: user, sender, text, mode: "steer" });
|
|
301
311
|
}
|
|
302
312
|
/** Slack will not accept a message with neither text nor blocks, so a turn
|
|
@@ -439,6 +449,11 @@ export class SlackChannel {
|
|
|
439
449
|
// The turn ended either way; a 👀 left up by a failed send looks like work.
|
|
440
450
|
await this.receipts.settleAfter(conversation, () => this.out.reply(channel, threadTs, reply), reply.meta);
|
|
441
451
|
}
|
|
452
|
+
/** A web session's thread: the root is the one message Pier posts into a
|
|
453
|
+
* channel's main flow (channels/handoff.ts). */
|
|
454
|
+
async openThread(chatId, note) {
|
|
455
|
+
return conversationId(chatId, await this.out.open(chatId, note));
|
|
456
|
+
}
|
|
442
457
|
/** The 👀 goes on the note itself: the turn it triggers has no message of
|
|
443
458
|
* the user's to carry them. */
|
|
444
459
|
async notify(conversation, note) {
|
|
@@ -449,6 +464,6 @@ export class SlackChannel {
|
|
|
449
464
|
}
|
|
450
465
|
const ts = await this.out.note(channel, threadTs, note);
|
|
451
466
|
if (ts && awaitsTurn(note.origin))
|
|
452
|
-
this.receipts.mark(conversation, channel, ts);
|
|
467
|
+
this.receipts.mark(conversation, channel, ts, note.at);
|
|
453
468
|
}
|
|
454
469
|
}
|
package/dist/channels/types.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// adapters and the Console view (type-only, so no node builtins here). Defaults
|
|
3
3
|
// are least-privilege, and platform values are seeds copied into a chat on
|
|
4
4
|
// discovery, not a runtime fallback: a switch means what it says.
|
|
5
|
-
const PLATFORMS = ["
|
|
5
|
+
const PLATFORMS = ["slack", "lark"];
|
|
6
6
|
/** Validate at the boundary: an unknown platform is a 404, not a new row. */
|
|
7
7
|
export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
|
|
8
8
|
/** Every adapter spells its ids `<chatId>` or `<chatId>/<thread>`, so the chat
|
|
@@ -14,7 +14,6 @@ export const defaultChannelConfig = () => ({
|
|
|
14
14
|
appToken: "",
|
|
15
15
|
requireMention: true,
|
|
16
16
|
requireBind: true,
|
|
17
|
-
topicMode: true,
|
|
18
17
|
cwd: "",
|
|
19
18
|
model: null,
|
|
20
19
|
thinking: null,
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ Usage
|
|
|
27
27
|
run a command with named secrets in its env
|
|
28
28
|
pier slack <subcommand> ... Slack from a shell, token from the vault (pier slack --help)
|
|
29
29
|
pier task <command> ... subagents and scheduled tasks from a shell (pier task --help)
|
|
30
|
+
pier web search|fetch ... the public web through the provider's hosted tools (pier web --help)
|
|
30
31
|
pier --version | --help
|
|
31
32
|
|
|
32
33
|
Options for "service install"
|
|
@@ -42,6 +43,9 @@ const say = (message) => void process.stdout.write(`${message}\n`);
|
|
|
42
43
|
/** Every socket route answers from memory and the database, so a Pier that
|
|
43
44
|
* takes longer than this is stuck, and an agent's shell must not hang with it. */
|
|
44
45
|
const SOCKET_TIMEOUT_MS = 30_000;
|
|
46
|
+
/** `/web` is the one route that waits on a provider: its own 90 s ceiling
|
|
47
|
+
* (`websearch/run.ts`) answers first, so this only catches a stuck Pier. */
|
|
48
|
+
const WEB_TIMEOUT_MS = 120_000;
|
|
45
49
|
/** Typed on the binding: only then does a call narrow the code after it. */
|
|
46
50
|
const die = (message) => {
|
|
47
51
|
process.stderr.write(`${message}\n`);
|
|
@@ -52,8 +56,8 @@ const argv = process.argv.slice(2);
|
|
|
52
56
|
const parsed = (() => {
|
|
53
57
|
try {
|
|
54
58
|
return parseArgs({
|
|
55
|
-
// `slack` and `
|
|
56
|
-
args: argv[0] === "slack" || argv[0] === "task" ? [argv[0]] : argv,
|
|
59
|
+
// `slack`, `task` and `web` own their options; only the name is parsed here.
|
|
60
|
+
args: argv[0] === "slack" || argv[0] === "task" || argv[0] === "web" ? [argv[0]] : argv,
|
|
57
61
|
allowPositionals: true,
|
|
58
62
|
strict: true,
|
|
59
63
|
options: {
|
|
@@ -125,6 +129,10 @@ else if (command === "task") {
|
|
|
125
129
|
const { runTaskCli } = await import("./tasks/cli.js");
|
|
126
130
|
process.exitCode = await runTaskCli(argv.slice(1), (params) => askPier("/task", { params }));
|
|
127
131
|
}
|
|
132
|
+
else if (command === "web") {
|
|
133
|
+
const { runWebCli } = await import("./websearch/cli.js");
|
|
134
|
+
process.exitCode = await runWebCli(argv.slice(1), (params) => askPier("/web", { params }, WEB_TIMEOUT_MS));
|
|
135
|
+
}
|
|
128
136
|
else if (command === "restart" || command === "reload") {
|
|
129
137
|
if (subcommand)
|
|
130
138
|
fail(`unexpected argument "${subcommand}"`);
|
|
@@ -277,11 +285,11 @@ function inject(wanted, values, cmd) {
|
|
|
277
285
|
* session (`PIER_SESSION_ID`, the harness variable mapped by the shim). Not
|
|
278
286
|
* running, silent, unreadable, or an identity or body Pier refuses, is one
|
|
279
287
|
* `pier:` line and exit 2 before any route reads the answer. */
|
|
280
|
-
async function askPier(path, body) {
|
|
288
|
+
async function askPier(path, body, timeout = SOCKET_TIMEOUT_MS) {
|
|
281
289
|
const { PIER_SOCK } = await import("./paths.js");
|
|
282
290
|
const answer = await new Promise((done, reject) => {
|
|
283
291
|
let responded = false;
|
|
284
|
-
const req = request({ socketPath: PIER_SOCK, method: "POST", path, headers: { "content-type": "application/json" }, timeout
|
|
292
|
+
const req = request({ socketPath: PIER_SOCK, method: "POST", path, headers: { "content-type": "application/json" }, timeout }, (res) => {
|
|
285
293
|
responded = true;
|
|
286
294
|
let raw = "";
|
|
287
295
|
const unreadable = () => reject(new Error(`unreadable answer from ${PIER_SOCK} (status ${String(res.statusCode ?? 0)})`));
|
|
@@ -297,7 +305,7 @@ async function askPier(path, body) {
|
|
|
297
305
|
}
|
|
298
306
|
});
|
|
299
307
|
});
|
|
300
|
-
req.on("timeout", () => req.destroy(new Error(`Pier did not answer within ${String(
|
|
308
|
+
req.on("timeout", () => req.destroy(new Error(`Pier did not answer within ${String(timeout / 1000)} s`)));
|
|
301
309
|
// A 413 arrives while the body is still being written; the EPIPE after it is not the news.
|
|
302
310
|
req.on("error", (err) => responded || reject(err));
|
|
303
311
|
req.end(JSON.stringify({ ...body, sessionId: process.env.PIER_SESSION_ID }));
|
package/dist/core/identity.js
CHANGED
|
@@ -26,8 +26,9 @@ export class SenderPrefix {
|
|
|
26
26
|
seen = new Map();
|
|
27
27
|
/** The line to put above this message, or `""` when the session already
|
|
28
28
|
* knows. `conversation` is the chat's `<channelId>:<conversationId>`, told
|
|
29
|
-
* once: a session never moves, but the rule stays the same as the rest.
|
|
30
|
-
|
|
29
|
+
* once: a session never moves, but the rule stays the same as the rest.
|
|
30
|
+
* `opaqueIds` is the platform's (`core/types.ts`): its ids buy nothing. */
|
|
31
|
+
next(sessionId, sender, at = Date.now(), conversation, opaqueIds = false) {
|
|
31
32
|
if (!sender?.id)
|
|
32
33
|
return "";
|
|
33
34
|
const last = this.seen.get(sessionId);
|
|
@@ -43,9 +44,14 @@ export class SenderPrefix {
|
|
|
43
44
|
// is the id, and `U123<U123>` would read as a broken record.
|
|
44
45
|
const id = sanitizeIdentity(sender.id);
|
|
45
46
|
const label = sanitizeIdentity(sender.name);
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
47
|
+
const named = opaqueIds ? label : label === id ? `<${id}>` : `${label}<${id}>`;
|
|
48
|
+
const who = newSpeaker ? named : "";
|
|
49
|
+
// A bare name has no `<>` to be told apart by, so it is only unambiguous
|
|
50
|
+
// next to the time: with opaque ids the clock is written whenever it is.
|
|
51
|
+
const clock = gap || newDay || (opaqueIds && !!who);
|
|
52
|
+
const when = clock ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
|
|
53
|
+
const place = opaqueIds ? conversation?.split(":")[0] : conversation;
|
|
54
|
+
const where = newPlace && place ? sanitizePlace(place) : "";
|
|
49
55
|
return `[${[who, when, where].filter(Boolean).join(" ")}]`;
|
|
50
56
|
}
|
|
51
57
|
forget(sessionId) {
|
|
@@ -55,18 +61,26 @@ export class SenderPrefix {
|
|
|
55
61
|
export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text;
|
|
56
62
|
// Only the shapes `next()` emits, newline included: a human typing
|
|
57
63
|
// `[14:23] on my way` is body text and must come back untouched.
|
|
58
|
-
const
|
|
64
|
+
const TIME = String.raw `(?<when>(?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})`;
|
|
65
|
+
const WITH_ID = new RegExp(String.raw `^\[(?:(?<name>[^\n[\]<>]*)<(?<id>[^\n[\]<>]+)>)? ?${TIME}? ?(?<where>[a-z]+:[^\s[\]<>]+)?\]\n`);
|
|
66
|
+
/** The opaque-ids shape: a name with no `<>`, told apart from body text by the
|
|
67
|
+
* time that always follows it, and a platform with no conversation after it.
|
|
68
|
+
* A line of its own reading `[meeting 14:23]` is the price. */
|
|
69
|
+
const NAMED = new RegExp(String.raw `^\[(?<name>[^\n[\]<>]*?) ${TIME}(?: (?<where>[a-z]+))?\]\n`);
|
|
59
70
|
/** Read back a header this module wrote: the prefix is for the model, and a
|
|
60
71
|
* surface showing a stored message renders the speaker its own way. */
|
|
61
72
|
export function splitSpeaker(text) {
|
|
62
|
-
const
|
|
63
|
-
|
|
73
|
+
const head = WITH_ID.exec(text);
|
|
74
|
+
const { id, when, where } = head?.groups ?? {};
|
|
75
|
+
// A name on its own proves nothing: try the shape that requires a time.
|
|
76
|
+
const m = id || when || where ? head : NAMED.exec(text);
|
|
77
|
+
if (!m?.groups)
|
|
64
78
|
return { text };
|
|
65
79
|
return {
|
|
66
|
-
...(m
|
|
67
|
-
...(m
|
|
68
|
-
...(m
|
|
69
|
-
...(m
|
|
80
|
+
...(m.groups.name ? { name: m.groups.name } : {}),
|
|
81
|
+
...(m.groups.id ? { id: m.groups.id } : {}),
|
|
82
|
+
...(m.groups.when ? { when: m.groups.when } : {}),
|
|
83
|
+
...(m.groups.where ? { where: m.groups.where } : {}),
|
|
70
84
|
text: text.slice(m[0].length),
|
|
71
85
|
};
|
|
72
86
|
}
|
|
@@ -94,3 +108,24 @@ export function readableTitle(title) {
|
|
|
94
108
|
.trim();
|
|
95
109
|
return said || title;
|
|
96
110
|
}
|
|
111
|
+
/** How a session is named where it is announced (a push, a handoff): its
|
|
112
|
+
* readable title, else its directory. Never empty — a listing that could
|
|
113
|
+
* not answer must not silence the message. One line: every caller puts it
|
|
114
|
+
* inside emphasis or a button, where a newline breaks the markup. */
|
|
115
|
+
export const sessionLabel = (s) => readableTitle(s?.title)?.replace(/\s+/g, " ").trim() || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
116
|
+
/** Distinct directories, newest session first: the ground `projectCwds` picks
|
|
117
|
+
* from. */
|
|
118
|
+
export const distinctCwds = (list) => [...new Set([...list].sort((a, b) => b.createdAt - a.createdAt).map((s) => s.cwd))];
|
|
119
|
+
/** The distinct directories less the worktrees: `wt` puts a checkout beside its
|
|
120
|
+
* repository as `<repo>.<branch>`, and the next conversation about a project
|
|
121
|
+
* belongs in the project. A worktree with no such sibling stays. What every
|
|
122
|
+
* directory picker — web New-session menu, Settings scope, IM panel — offers. */
|
|
123
|
+
export function projectCwds(list) {
|
|
124
|
+
const all = distinctCwds(list);
|
|
125
|
+
const known = new Set(all);
|
|
126
|
+
return all.filter((cwd) => {
|
|
127
|
+
const slash = cwd.lastIndexOf("/");
|
|
128
|
+
const dot = cwd.indexOf(".", slash + 2); // not a leading dot: `.pier` is a name
|
|
129
|
+
return dot < 0 || !known.has(cwd.slice(0, dot));
|
|
130
|
+
});
|
|
131
|
+
}
|