@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 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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kiipu/cli",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Kiipu CLI for local authentication, doctor checks, and direct post actions.",
5
5
  "license": "MIT",
6
6
  "type": "module",