@kiipu/cli 0.0.7 → 0.0.8
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 +30 -0
- package/dist/commands/ask.js +234 -0
- package/dist/commands/help.js +37 -0
- package/dist/index.js +14 -1
- package/dist/lib/ask-client.js +276 -0
- package/dist/lib/ask-formatters.js +114 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Publish to Kiipu from your terminal.
|
|
|
7
7
|
Use it to:
|
|
8
8
|
|
|
9
9
|
- sign in on the current device
|
|
10
|
+
- ask questions over your saved posts
|
|
10
11
|
- publish posts from the command line
|
|
11
12
|
- delete, restore, or permanently remove posts by id
|
|
12
13
|
- verify local authentication and API access with `kiipu doctor`
|
|
@@ -39,10 +40,17 @@ kiipu post create "Hello Kiipu"
|
|
|
39
40
|
kiipu doctor
|
|
40
41
|
```
|
|
41
42
|
|
|
43
|
+
4. Ask over your saved posts:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
kiipu ask "What did I save about the roadmap?"
|
|
47
|
+
```
|
|
48
|
+
|
|
42
49
|
## Example Workflow
|
|
43
50
|
|
|
44
51
|
```bash
|
|
45
52
|
kiipu auth login
|
|
53
|
+
kiipu ask "What should I follow up on?"
|
|
46
54
|
kiipu post create "Ship the beta today"
|
|
47
55
|
kiipu auth status
|
|
48
56
|
```
|
|
@@ -82,6 +90,23 @@ kiipu post restore --id post_123
|
|
|
82
90
|
kiipu post purge --id post_123
|
|
83
91
|
```
|
|
84
92
|
|
|
93
|
+
## Ask
|
|
94
|
+
|
|
95
|
+
Ask a new question and stream the answer:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
kiipu ask "What did I save about the roadmap?"
|
|
99
|
+
kiipu ask --question "What should I follow up on?"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Continue a conversation or inspect Ask history:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
kiipu ask --conversation-id conv_123 "What should I do next?"
|
|
106
|
+
kiipu ask history --limit 10
|
|
107
|
+
kiipu ask show --id conv_123
|
|
108
|
+
```
|
|
109
|
+
|
|
85
110
|
## Core Commands
|
|
86
111
|
|
|
87
112
|
```bash
|
|
@@ -89,6 +114,10 @@ kiipu auth login
|
|
|
89
114
|
kiipu auth status
|
|
90
115
|
kiipu auth logout
|
|
91
116
|
|
|
117
|
+
kiipu ask "What did I save about the roadmap?"
|
|
118
|
+
kiipu ask history --limit 10
|
|
119
|
+
kiipu ask show --id conv_123
|
|
120
|
+
|
|
92
121
|
kiipu post create "Hello Kiipu"
|
|
93
122
|
kiipu post delete --id post_123
|
|
94
123
|
kiipu post restore --id post_123
|
|
@@ -122,5 +151,6 @@ See the full command reference in the terminal:
|
|
|
122
151
|
```bash
|
|
123
152
|
kiipu --help
|
|
124
153
|
kiipu auth --help
|
|
154
|
+
kiipu ask --help
|
|
125
155
|
kiipu post --help
|
|
126
156
|
```
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { KiipuAskClient } from '../lib/ask-client.js';
|
|
2
|
+
import { formatAskFooter, formatConversationDetail, formatConversationHistory, } from '../lib/ask-formatters.js';
|
|
3
|
+
import { hasFlag, readFlag } from '../utils/args.js';
|
|
4
|
+
const sourceModes = new Set(['fresh', 'locked']);
|
|
5
|
+
function error(message, data) {
|
|
6
|
+
return {
|
|
7
|
+
ok: false,
|
|
8
|
+
message,
|
|
9
|
+
...(data ? { data, json: { ok: false, message, ...data } } : {}),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function usage(action) {
|
|
13
|
+
if (action === 'history') {
|
|
14
|
+
return 'Usage: kiipu ask history [--query <q>] [--limit <n>] [--archived]';
|
|
15
|
+
}
|
|
16
|
+
if (action === 'show') {
|
|
17
|
+
return 'Usage: kiipu ask show --id <conversationId>';
|
|
18
|
+
}
|
|
19
|
+
return [
|
|
20
|
+
'Usage: kiipu ask "question"',
|
|
21
|
+
' or: kiipu ask --question "question"',
|
|
22
|
+
' or: kiipu ask --conversation-id <id> "follow-up"',
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
function getAskClient(config) {
|
|
26
|
+
const apiKey = config.apiKey ?? process.env.KIIPU_API_KEY ?? '';
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
return {
|
|
29
|
+
error: error('Kiipu API key is missing. Run `kiipu auth login` first.', {
|
|
30
|
+
code: 'missing_api_key',
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
client: new KiipuAskClient({
|
|
36
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
37
|
+
apiKey,
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function stripKnownFlags(args, flagsWithValues) {
|
|
42
|
+
const positional = [];
|
|
43
|
+
const flags = new Set(flagsWithValues);
|
|
44
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
45
|
+
const arg = args[index];
|
|
46
|
+
if (!arg) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (flags.has(arg)) {
|
|
50
|
+
index += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!arg.startsWith('--')) {
|
|
54
|
+
positional.push(arg);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return positional;
|
|
58
|
+
}
|
|
59
|
+
function parseTopK(args) {
|
|
60
|
+
const raw = readFlag(args, '--top-k');
|
|
61
|
+
if (!raw) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const value = Number(raw);
|
|
65
|
+
if (!Number.isInteger(value) || value < 1 || value > 20) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function parseLimit(args) {
|
|
71
|
+
const raw = readFlag(args, '--limit');
|
|
72
|
+
if (!raw) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const value = Number(raw);
|
|
76
|
+
if (!Number.isInteger(value) || value < 1 || value > 50) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function parseSourceMode(args) {
|
|
82
|
+
const value = readFlag(args, '--source-mode');
|
|
83
|
+
if (!value) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return sourceModes.has(value) ? value : null;
|
|
87
|
+
}
|
|
88
|
+
function readQuestion(args) {
|
|
89
|
+
const explicit = readFlag(args, '--question')?.trim();
|
|
90
|
+
if (explicit) {
|
|
91
|
+
return explicit;
|
|
92
|
+
}
|
|
93
|
+
return stripKnownFlags(args, [
|
|
94
|
+
'--question',
|
|
95
|
+
'--conversation-id',
|
|
96
|
+
'--top-k',
|
|
97
|
+
'--source-mode',
|
|
98
|
+
])
|
|
99
|
+
.join(' ')
|
|
100
|
+
.trim();
|
|
101
|
+
}
|
|
102
|
+
async function handleHistory(config, args) {
|
|
103
|
+
const { client, error: clientError } = getAskClient(config);
|
|
104
|
+
if (!client) {
|
|
105
|
+
return clientError;
|
|
106
|
+
}
|
|
107
|
+
const limit = parseLimit(args);
|
|
108
|
+
if (limit === null) {
|
|
109
|
+
return error(`Invalid --limit value. ${usage('history')}`, { code: 'invalid_limit' });
|
|
110
|
+
}
|
|
111
|
+
const response = await client.listConversations({
|
|
112
|
+
query: readFlag(args, '--query'),
|
|
113
|
+
limit,
|
|
114
|
+
archived: hasFlag(args, '--archived'),
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
return error(response.error.message, response.error);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
message: formatConversationHistory(hasFlag(args, '--archived') ? 'Archived Ask conversations' : 'Ask conversations', response.data.items, response.data.nextCursor),
|
|
122
|
+
data: response.data,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
async function handleShow(config, args) {
|
|
126
|
+
const { client, error: clientError } = getAskClient(config);
|
|
127
|
+
if (!client) {
|
|
128
|
+
return clientError;
|
|
129
|
+
}
|
|
130
|
+
const id = readFlag(args, '--id')?.trim();
|
|
131
|
+
if (!id) {
|
|
132
|
+
return error(usage('show'), { code: 'missing_conversation_id' });
|
|
133
|
+
}
|
|
134
|
+
const response = await client.getConversation(id);
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
return error(response.error.message, response.error);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
message: formatConversationDetail(response.data),
|
|
141
|
+
data: response.data,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
async function handleQuestion(config, args, options) {
|
|
145
|
+
const { client, error: clientError } = getAskClient(config);
|
|
146
|
+
if (!client) {
|
|
147
|
+
return clientError;
|
|
148
|
+
}
|
|
149
|
+
const question = readQuestion(args);
|
|
150
|
+
if (!question) {
|
|
151
|
+
return error(usage(), { code: 'missing_question' });
|
|
152
|
+
}
|
|
153
|
+
const topK = parseTopK(args);
|
|
154
|
+
if (topK === null) {
|
|
155
|
+
return error(`Invalid --top-k value. ${usage()}`, { code: 'invalid_top_k' });
|
|
156
|
+
}
|
|
157
|
+
const sourceMode = parseSourceMode(args);
|
|
158
|
+
if (sourceMode === null) {
|
|
159
|
+
return error('Invalid --source-mode value. Use fresh or locked.', {
|
|
160
|
+
code: 'invalid_source_mode',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const result = {
|
|
164
|
+
answer: '',
|
|
165
|
+
conversationId: readFlag(args, '--conversation-id')?.trim() || undefined,
|
|
166
|
+
sources: [],
|
|
167
|
+
};
|
|
168
|
+
const targetConversationId = result.conversationId ?? 'new';
|
|
169
|
+
for await (const event of client.streamMessage({
|
|
170
|
+
conversationId: targetConversationId,
|
|
171
|
+
question,
|
|
172
|
+
topK,
|
|
173
|
+
sourceMode,
|
|
174
|
+
})) {
|
|
175
|
+
switch (event.type) {
|
|
176
|
+
case 'meta':
|
|
177
|
+
result.conversationId = event.conversationId;
|
|
178
|
+
result.title = event.title;
|
|
179
|
+
break;
|
|
180
|
+
case 'sources':
|
|
181
|
+
result.sources = event.sources;
|
|
182
|
+
break;
|
|
183
|
+
case 'delta':
|
|
184
|
+
result.answer += event.text;
|
|
185
|
+
if (options.stream) {
|
|
186
|
+
options.write?.(event.text);
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
case 'done':
|
|
190
|
+
result.turnId = event.turnId;
|
|
191
|
+
result.usage = {
|
|
192
|
+
inputTokens: event.inputTokens,
|
|
193
|
+
outputTokens: event.outputTokens,
|
|
194
|
+
latencyMs: event.latencyMs,
|
|
195
|
+
};
|
|
196
|
+
break;
|
|
197
|
+
case 'title':
|
|
198
|
+
result.conversationId = event.conversationId;
|
|
199
|
+
result.title = event.title;
|
|
200
|
+
break;
|
|
201
|
+
case 'error':
|
|
202
|
+
return error(event.message, {
|
|
203
|
+
code: event.code,
|
|
204
|
+
answer: result.answer,
|
|
205
|
+
conversationId: result.conversationId,
|
|
206
|
+
title: result.title,
|
|
207
|
+
turnId: result.turnId,
|
|
208
|
+
sources: result.sources,
|
|
209
|
+
usage: result.usage,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const footer = formatAskFooter(result);
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
message: options.stream ? footer : `${result.answer}${footer}`,
|
|
217
|
+
data: result,
|
|
218
|
+
json: {
|
|
219
|
+
ok: true,
|
|
220
|
+
...result,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
export async function runAskCommand(config, args, options = {}) {
|
|
225
|
+
const action = args[1];
|
|
226
|
+
const actionArgs = args.slice(2);
|
|
227
|
+
if (action === 'history') {
|
|
228
|
+
return handleHistory(config, actionArgs);
|
|
229
|
+
}
|
|
230
|
+
if (action === 'show') {
|
|
231
|
+
return handleShow(config, actionArgs);
|
|
232
|
+
}
|
|
233
|
+
return handleQuestion(config, args.slice(1), options);
|
|
234
|
+
}
|
package/dist/commands/help.js
CHANGED
|
@@ -2,6 +2,40 @@ function block(lines) {
|
|
|
2
2
|
return lines.join('\n');
|
|
3
3
|
}
|
|
4
4
|
export function getHelpResult(command) {
|
|
5
|
+
if (command === 'ask') {
|
|
6
|
+
return {
|
|
7
|
+
ok: true,
|
|
8
|
+
message: block([
|
|
9
|
+
'Kiipu CLI',
|
|
10
|
+
'',
|
|
11
|
+
'Usage:',
|
|
12
|
+
' kiipu ask "question"',
|
|
13
|
+
' kiipu ask --question "question"',
|
|
14
|
+
' kiipu ask --conversation-id <conversationId> "follow-up"',
|
|
15
|
+
' kiipu ask history [--query <q>] [--limit <n>] [--archived]',
|
|
16
|
+
' kiipu ask show --id <conversationId>',
|
|
17
|
+
'',
|
|
18
|
+
'Description:',
|
|
19
|
+
' Ask questions over your Kiipu posts using the same conversation-aware Ask surface as the web app.',
|
|
20
|
+
'',
|
|
21
|
+
'Options:',
|
|
22
|
+
' --question "<text>" Question to ask when not using a positional argument',
|
|
23
|
+
' --conversation-id <id> Continue an existing Ask conversation',
|
|
24
|
+
' --top-k <1-20> Optional retrieval count for Ask',
|
|
25
|
+
' --source-mode <value> One of fresh or locked',
|
|
26
|
+
' --query "<query>" Filter Ask history rows',
|
|
27
|
+
' --limit <1-50> Limit Ask history rows',
|
|
28
|
+
' --archived List archived Ask conversations',
|
|
29
|
+
' --id <conversationId> Conversation id for ask show',
|
|
30
|
+
'',
|
|
31
|
+
'Examples:',
|
|
32
|
+
' kiipu ask "What did I save about the roadmap?"',
|
|
33
|
+
' kiipu ask --conversation-id conv_123 "What should I do next?"',
|
|
34
|
+
' kiipu ask history --limit 10',
|
|
35
|
+
' kiipu ask show --id conv_123',
|
|
36
|
+
]),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
5
39
|
if (command === 'post') {
|
|
6
40
|
return {
|
|
7
41
|
ok: true,
|
|
@@ -139,12 +173,15 @@ export function getHelpResult(command) {
|
|
|
139
173
|
' kiipu <command> [options]',
|
|
140
174
|
'',
|
|
141
175
|
'Core commands:',
|
|
176
|
+
' ask Ask questions over your Kiipu posts and browse Ask conversations',
|
|
142
177
|
' post Create, browse, update, and delete posts with direct CLI arguments',
|
|
143
178
|
' auth Manage local API key authentication',
|
|
144
179
|
' doctor Check local setup, API access, and wrapper readiness',
|
|
145
180
|
' skills Show where the Claude Code plugin package lives',
|
|
146
181
|
'',
|
|
147
182
|
'Core examples:',
|
|
183
|
+
' kiipu ask "What changed in my roadmap notes?"',
|
|
184
|
+
' kiipu ask history --limit 10',
|
|
148
185
|
' kiipu post create "Hello Kiipu"',
|
|
149
186
|
' kiipu post list --starred',
|
|
150
187
|
' kiipu post search "Hello"',
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import './config/load-env.js';
|
|
3
3
|
import { createDefaultConfig, loadKiipuConfig } from './config/config.js';
|
|
4
|
+
import { runAskCommand } from './commands/ask.js';
|
|
4
5
|
import { runAuthCommand } from './commands/auth.js';
|
|
5
6
|
import { runDoctorCommand } from './commands/doctor.js';
|
|
6
7
|
import { getHelpResult } from './commands/help.js';
|
|
@@ -10,7 +11,7 @@ import { readFlag, hasFlag } from './utils/args.js';
|
|
|
10
11
|
import { CLI_VERSION } from './version.js';
|
|
11
12
|
function printResult(result, asJson) {
|
|
12
13
|
if (asJson) {
|
|
13
|
-
console.log(JSON.stringify(result, null, 2));
|
|
14
|
+
console.log(JSON.stringify(result.json ?? result, null, 2));
|
|
14
15
|
}
|
|
15
16
|
else {
|
|
16
17
|
console.log(result.message);
|
|
@@ -44,6 +45,11 @@ async function main() {
|
|
|
44
45
|
'--title',
|
|
45
46
|
'--visibility',
|
|
46
47
|
'--tags',
|
|
48
|
+
'--question',
|
|
49
|
+
'--conversation-id',
|
|
50
|
+
'--top-k',
|
|
51
|
+
'--source-mode',
|
|
52
|
+
'--limit',
|
|
47
53
|
]);
|
|
48
54
|
const positionalArgs = normalizedArgs.filter((arg, index, all) => {
|
|
49
55
|
if (arg === '--json' ||
|
|
@@ -84,6 +90,13 @@ async function main() {
|
|
|
84
90
|
result = await runPostCommand(config, commandArgs);
|
|
85
91
|
return printResult(result, asJson);
|
|
86
92
|
}
|
|
93
|
+
if (command === 'ask') {
|
|
94
|
+
result = await runAskCommand(config, commandArgs, {
|
|
95
|
+
stream: !asJson,
|
|
96
|
+
write: (chunk) => process.stdout.write(chunk),
|
|
97
|
+
});
|
|
98
|
+
return printResult(result, asJson);
|
|
99
|
+
}
|
|
87
100
|
if (command === 'auth') {
|
|
88
101
|
const action = subcommand;
|
|
89
102
|
if (!action || !['login', 'status', 'logout'].includes(action)) {
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { TextDecoder } from 'node:util';
|
|
2
|
+
function isRecord(input) {
|
|
3
|
+
return typeof input === 'object' && input !== null;
|
|
4
|
+
}
|
|
5
|
+
function buildError(message, code = 'request_failed') {
|
|
6
|
+
return {
|
|
7
|
+
ok: false,
|
|
8
|
+
error: {
|
|
9
|
+
code,
|
|
10
|
+
message,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function getErrorMessage(payload, fallback) {
|
|
15
|
+
if (!isRecord(payload)) {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
if (typeof payload.message === 'string') {
|
|
19
|
+
return payload.message;
|
|
20
|
+
}
|
|
21
|
+
if (isRecord(payload.message) && typeof payload.message.message === 'string') {
|
|
22
|
+
return payload.message.message;
|
|
23
|
+
}
|
|
24
|
+
if (isRecord(payload.error) && typeof payload.error.message === 'string') {
|
|
25
|
+
return payload.error.message;
|
|
26
|
+
}
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
function getErrorCode(payload, fallback = 'request_failed') {
|
|
30
|
+
if (!isRecord(payload)) {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
if (typeof payload.code === 'string') {
|
|
34
|
+
return payload.code;
|
|
35
|
+
}
|
|
36
|
+
if (isRecord(payload.message) && typeof payload.message.code === 'string') {
|
|
37
|
+
return payload.message.code;
|
|
38
|
+
}
|
|
39
|
+
if (isRecord(payload.error) && typeof payload.error.code === 'string') {
|
|
40
|
+
return payload.error.code;
|
|
41
|
+
}
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
function parseSource(input) {
|
|
45
|
+
if (!isRecord(input)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const postId = typeof input.postId === 'string' ? input.postId : '';
|
|
49
|
+
const snippet = typeof input.snippet === 'string' ? input.snippet : '';
|
|
50
|
+
if (!postId || !snippet) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
index: typeof input.index === 'number' ? input.index : 0,
|
|
55
|
+
postId,
|
|
56
|
+
title: typeof input.title === 'string' ? input.title : null,
|
|
57
|
+
snippet,
|
|
58
|
+
score: typeof input.score === 'number' ? input.score : 0,
|
|
59
|
+
createdAt: typeof input.createdAt === 'string' ? input.createdAt : undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function parseSources(input) {
|
|
63
|
+
return Array.isArray(input) ? input.map(parseSource).filter((source) => Boolean(source)) : [];
|
|
64
|
+
}
|
|
65
|
+
function parseAskEvent(input) {
|
|
66
|
+
if (!isRecord(input) || typeof input.type !== 'string') {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
switch (input.type) {
|
|
70
|
+
case 'meta':
|
|
71
|
+
if (typeof input.conversationId !== 'string') {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
type: 'meta',
|
|
76
|
+
conversationId: input.conversationId,
|
|
77
|
+
isNew: Boolean(input.isNew),
|
|
78
|
+
title: typeof input.title === 'string' ? input.title : null,
|
|
79
|
+
};
|
|
80
|
+
case 'sources':
|
|
81
|
+
return {
|
|
82
|
+
type: 'sources',
|
|
83
|
+
sources: parseSources(input.sources),
|
|
84
|
+
locked: Boolean(input.locked),
|
|
85
|
+
retrievalMode: input.retrievalMode === 'semantic' || input.retrievalMode === 'temporal_list'
|
|
86
|
+
? input.retrievalMode
|
|
87
|
+
: undefined,
|
|
88
|
+
totalCount: typeof input.totalCount === 'number' ? input.totalCount : undefined,
|
|
89
|
+
truncated: typeof input.truncated === 'boolean' ? input.truncated : undefined,
|
|
90
|
+
};
|
|
91
|
+
case 'delta':
|
|
92
|
+
return typeof input.text === 'string' ? { type: 'delta', text: input.text } : null;
|
|
93
|
+
case 'done':
|
|
94
|
+
if (typeof input.turnId !== 'string') {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
type: 'done',
|
|
99
|
+
turnId: input.turnId,
|
|
100
|
+
inputTokens: typeof input.inputTokens === 'number' ? input.inputTokens : 0,
|
|
101
|
+
outputTokens: typeof input.outputTokens === 'number' ? input.outputTokens : 0,
|
|
102
|
+
latencyMs: typeof input.latencyMs === 'number' ? input.latencyMs : 0,
|
|
103
|
+
};
|
|
104
|
+
case 'title':
|
|
105
|
+
if (typeof input.conversationId !== 'string' || typeof input.title !== 'string') {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return { type: 'title', conversationId: input.conversationId, title: input.title };
|
|
109
|
+
case 'error':
|
|
110
|
+
return {
|
|
111
|
+
type: 'error',
|
|
112
|
+
code: typeof input.code === 'string' ? input.code : 'unknown',
|
|
113
|
+
message: typeof input.message === 'string' ? input.message : 'Unknown Ask error.',
|
|
114
|
+
};
|
|
115
|
+
default:
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function extractDataField(frame) {
|
|
120
|
+
const dataLines = [];
|
|
121
|
+
for (const line of frame.split('\n')) {
|
|
122
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
123
|
+
if (normalized.startsWith('data:')) {
|
|
124
|
+
dataLines.push(normalized.slice(5).replace(/^ /, ''));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return dataLines.length > 0 ? dataLines.join('\n') : null;
|
|
128
|
+
}
|
|
129
|
+
async function readJson(response) {
|
|
130
|
+
try {
|
|
131
|
+
return await response.json();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export class KiipuAskClient {
|
|
138
|
+
config;
|
|
139
|
+
constructor(config) {
|
|
140
|
+
this.config = config;
|
|
141
|
+
}
|
|
142
|
+
async requestJson(path, init) {
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
response = await fetch(`${this.config.apiBaseUrl}${path}`, {
|
|
146
|
+
...init,
|
|
147
|
+
headers: {
|
|
148
|
+
'Content-Type': 'application/json',
|
|
149
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
150
|
+
...(init.headers ?? {}),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return buildError(`Kiipu API is unreachable at ${this.config.apiBaseUrl}.`, 'api_unreachable');
|
|
156
|
+
}
|
|
157
|
+
const payload = await readJson(response);
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
return buildError(getErrorMessage(payload, `Request failed with ${response.status}.`), getErrorCode(payload, `http_${response.status}`));
|
|
160
|
+
}
|
|
161
|
+
const data = isRecord(payload) && 'data' in payload ? payload.data : payload;
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
data: data,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
listConversations(input) {
|
|
168
|
+
const search = new URLSearchParams();
|
|
169
|
+
if (input.query) {
|
|
170
|
+
search.set('q', input.query);
|
|
171
|
+
}
|
|
172
|
+
if (input.limit) {
|
|
173
|
+
search.set('limit', String(input.limit));
|
|
174
|
+
}
|
|
175
|
+
if (input.archived) {
|
|
176
|
+
search.set('view', 'archived');
|
|
177
|
+
}
|
|
178
|
+
const query = search.toString();
|
|
179
|
+
return this.requestJson(`/ai/conversations${query ? `?${query}` : ''}`, { method: 'GET' });
|
|
180
|
+
}
|
|
181
|
+
getConversation(id) {
|
|
182
|
+
return this.requestJson(`/ai/conversations/${encodeURIComponent(id)}`, {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async *streamMessage(input) {
|
|
187
|
+
const body = {
|
|
188
|
+
question: input.question,
|
|
189
|
+
...(input.topK ? { topK: input.topK } : {}),
|
|
190
|
+
...(input.sourceMode ? { sourceMode: input.sourceMode } : {}),
|
|
191
|
+
};
|
|
192
|
+
let response;
|
|
193
|
+
try {
|
|
194
|
+
response = await fetch(`${this.config.apiBaseUrl}/ai/conversations/${encodeURIComponent(input.conversationId)}/messages`, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
199
|
+
Accept: 'text/event-stream',
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify(body),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
yield {
|
|
206
|
+
type: 'error',
|
|
207
|
+
code: 'network_error',
|
|
208
|
+
message: error instanceof Error ? error.message : 'Network request failed.',
|
|
209
|
+
};
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!response.ok || !response.body) {
|
|
213
|
+
const payload = await readJson(response);
|
|
214
|
+
yield {
|
|
215
|
+
type: 'error',
|
|
216
|
+
code: getErrorCode(payload, `http_${response.status}`),
|
|
217
|
+
message: getErrorMessage(payload, `Request failed with ${response.status}.`),
|
|
218
|
+
};
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
let settled = false;
|
|
222
|
+
const decoder = new TextDecoder();
|
|
223
|
+
const reader = response.body.getReader();
|
|
224
|
+
let buffer = '';
|
|
225
|
+
try {
|
|
226
|
+
while (true) {
|
|
227
|
+
const { value, done } = await reader.read();
|
|
228
|
+
if (done) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
buffer += decoder.decode(value, { stream: true });
|
|
232
|
+
let separator = buffer.indexOf('\n\n');
|
|
233
|
+
while (separator !== -1) {
|
|
234
|
+
const frame = buffer.slice(0, separator);
|
|
235
|
+
buffer = buffer.slice(separator + 2);
|
|
236
|
+
const data = extractDataField(frame);
|
|
237
|
+
if (data && data !== '[DONE]') {
|
|
238
|
+
try {
|
|
239
|
+
const event = parseAskEvent(JSON.parse(data));
|
|
240
|
+
if (event) {
|
|
241
|
+
if (event.type === 'done' || event.type === 'error') {
|
|
242
|
+
settled = true;
|
|
243
|
+
}
|
|
244
|
+
yield event;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Ignore malformed SSE frames from intermediaries.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
separator = buffer.indexOf('\n\n');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (!settled) {
|
|
257
|
+
yield {
|
|
258
|
+
type: 'error',
|
|
259
|
+
code: 'stream_error',
|
|
260
|
+
message: error instanceof Error ? error.message : 'Stream interrupted.',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
reader.releaseLock();
|
|
267
|
+
}
|
|
268
|
+
if (!settled) {
|
|
269
|
+
yield {
|
|
270
|
+
type: 'error',
|
|
271
|
+
code: 'stream_truncated',
|
|
272
|
+
message: 'The response ended unexpectedly. Please try again.',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
function formatTimestamp(value) {
|
|
2
|
+
if (!value) {
|
|
3
|
+
return 'unknown time';
|
|
4
|
+
}
|
|
5
|
+
const parsed = new Date(value);
|
|
6
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
return parsed.toLocaleString('en-US', {
|
|
10
|
+
year: 'numeric',
|
|
11
|
+
month: 'short',
|
|
12
|
+
day: '2-digit',
|
|
13
|
+
hour: '2-digit',
|
|
14
|
+
minute: '2-digit',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function truncate(value, maxLength) {
|
|
18
|
+
const normalized = value.trim().replace(/\s+/g, ' ');
|
|
19
|
+
if (normalized.length <= maxLength) {
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
return `${normalized.slice(0, maxLength - 3)}...`;
|
|
23
|
+
}
|
|
24
|
+
function formatSourceLine(source) {
|
|
25
|
+
const title = source.title?.trim() || '(untitled)';
|
|
26
|
+
const score = Number.isFinite(source.score) ? ` score ${source.score.toFixed(3)}` : '';
|
|
27
|
+
return `[${source.index}] ${title} (${source.postId})${score}`;
|
|
28
|
+
}
|
|
29
|
+
export function formatAskFooter(input) {
|
|
30
|
+
const lines = ['', 'Sources:'];
|
|
31
|
+
if (input.sources.length === 0) {
|
|
32
|
+
lines.push(' none');
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
for (const source of input.sources) {
|
|
36
|
+
lines.push(` ${formatSourceLine(source)}`);
|
|
37
|
+
lines.push(` ${truncate(source.snippet, 140)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const usage = input.usage;
|
|
41
|
+
if (usage) {
|
|
42
|
+
lines.push('', `Usage: input ${usage.inputTokens}, output ${usage.outputTokens}, latency ${usage.latencyMs}ms`);
|
|
43
|
+
}
|
|
44
|
+
if (input.conversationId) {
|
|
45
|
+
lines.push(`Conversation: ${input.conversationId}`);
|
|
46
|
+
}
|
|
47
|
+
if (input.title) {
|
|
48
|
+
lines.push(`Title: ${input.title}`);
|
|
49
|
+
}
|
|
50
|
+
if (input.turnId) {
|
|
51
|
+
lines.push(`Turn: ${input.turnId}`);
|
|
52
|
+
}
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
export function formatConversationHistory(title, conversations, nextCursor) {
|
|
56
|
+
const lines = [title, ''];
|
|
57
|
+
if (conversations.length === 0) {
|
|
58
|
+
lines.push('No Ask conversations found.');
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
61
|
+
for (const item of conversations) {
|
|
62
|
+
const label = item.title?.trim() || truncate(item.preview, 80) || '(untitled)';
|
|
63
|
+
const flags = [item.pinnedAt ? 'pinned' : '', item.archivedAt ? 'archived' : '']
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.join(', ');
|
|
66
|
+
lines.push(`${item.id} ${formatTimestamp(item.lastMessageAt)}`);
|
|
67
|
+
lines.push(` ${label}`);
|
|
68
|
+
if (flags) {
|
|
69
|
+
lines.push(` flags: ${flags}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push('');
|
|
72
|
+
}
|
|
73
|
+
if (nextCursor) {
|
|
74
|
+
lines.push(`Next cursor: ${nextCursor}`);
|
|
75
|
+
}
|
|
76
|
+
if (!nextCursor && lines[lines.length - 1] === '') {
|
|
77
|
+
lines.pop();
|
|
78
|
+
}
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
function formatTurn(turn) {
|
|
82
|
+
const role = turn.role === 'user' ? 'User' : 'Assistant';
|
|
83
|
+
const lines = [`${role} ${turn.id} ${formatTimestamp(turn.createdAt)} status: ${turn.status}`];
|
|
84
|
+
if (turn.errorCode) {
|
|
85
|
+
lines.push(`error: ${turn.errorCode}`);
|
|
86
|
+
}
|
|
87
|
+
lines.push(turn.content.trim() || '(empty)');
|
|
88
|
+
if (turn.sources && turn.sources.length > 0) {
|
|
89
|
+
lines.push('sources:');
|
|
90
|
+
for (const source of turn.sources) {
|
|
91
|
+
lines.push(` ${formatSourceLine(source)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
export function formatConversationDetail(detail) {
|
|
97
|
+
const lines = [
|
|
98
|
+
detail.title?.trim() || '(untitled Ask conversation)',
|
|
99
|
+
'',
|
|
100
|
+
`id: ${detail.id}`,
|
|
101
|
+
`created: ${formatTimestamp(detail.createdAt)}`,
|
|
102
|
+
`updated: ${formatTimestamp(detail.lastMessageAt)}`,
|
|
103
|
+
'',
|
|
104
|
+
];
|
|
105
|
+
if (detail.turns.length === 0) {
|
|
106
|
+
lines.push('No turns found.');
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
for (const turn of detail.turns) {
|
|
110
|
+
lines.push(formatTurn(turn), '');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return lines.slice(0, -1).join('\n');
|
|
114
|
+
}
|