@hevmind/ask 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/bin/ask-launcher.mjs +110 -0
- package/bin/ask.mjs +4 -0
- package/openapi.yaml +363 -0
- package/package.json +61 -0
- package/skills/build-digest/SKILL.md +164 -0
- package/src/components/SearchOverlay.astro +1375 -0
- package/src/components/markdown.ts +107 -0
- package/src/digest/build.ts +432 -0
- package/src/digest/cli.ts +148 -0
- package/src/digest/expand.ts +24 -0
- package/src/digest/facts.ts +77 -0
- package/src/digest/frontmatter.ts +41 -0
- package/src/digest/read.ts +63 -0
- package/src/digest/schema.ts +185 -0
- package/src/digest/verify.ts +116 -0
- package/src/endpoint.ts +247 -0
- package/src/index.ts +2 -0
- package/src/integration.ts +146 -0
- package/src/llm.ts +239 -0
- package/src/observability.ts +213 -0
- package/src/search/chunk.ts +137 -0
- package/src/search/index.ts +44 -0
- package/src/search/loop.ts +525 -0
- package/src/search/prefilter.ts +93 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// PostHog LLM observability for the agentic answer path.
|
|
2
|
+
//
|
|
3
|
+
// Emits $ai_generation / $ai_span / $ai_trace events over `fetch` so each
|
|
4
|
+
// request shows up in PostHog's LLM analytics with model, token, latency, and
|
|
5
|
+
// trace structure. Like `llm.ts`, this stays free of runtime dependencies and
|
|
6
|
+
// edge-runtime friendly — it speaks PostHog's capture API directly rather than
|
|
7
|
+
// pulling in `posthog-node`. Mirrors the pattern used in ../mind
|
|
8
|
+
// (site/src/lib/agent-core.mjs).
|
|
9
|
+
//
|
|
10
|
+
// Everything degrades: with no PostHog key configured, `makeTelemetry` returns
|
|
11
|
+
// a no-op sink, so the agentic path behaves exactly as before.
|
|
12
|
+
|
|
13
|
+
import type { AnthropicUsage } from './llm.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How much prompt/response text ships to PostHog.
|
|
17
|
+
* - `off`: metadata only (model, tokens, latency, tool names). No text.
|
|
18
|
+
* - `redacted`: conversation + tool calls, but tool_result bodies are redacted.
|
|
19
|
+
* - `full` (default): everything. The corpus is public docs, so this is safe.
|
|
20
|
+
*/
|
|
21
|
+
export type CaptureMode = 'off' | 'redacted' | 'full';
|
|
22
|
+
|
|
23
|
+
export interface GenerationEvent {
|
|
24
|
+
spanId: string;
|
|
25
|
+
spanName: string;
|
|
26
|
+
model: string;
|
|
27
|
+
input: unknown;
|
|
28
|
+
output: unknown;
|
|
29
|
+
usage?: AnthropicUsage;
|
|
30
|
+
latencyMs: number;
|
|
31
|
+
httpStatus?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SpanEvent {
|
|
35
|
+
spanId: string;
|
|
36
|
+
parentId?: string;
|
|
37
|
+
name: string;
|
|
38
|
+
input?: unknown;
|
|
39
|
+
output?: unknown;
|
|
40
|
+
ok: boolean;
|
|
41
|
+
latencyMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TraceEvent {
|
|
45
|
+
name?: string;
|
|
46
|
+
latencyMs: number;
|
|
47
|
+
ok: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A telemetry sink. The no-op variant has the same shape so callers never branch. */
|
|
51
|
+
export interface Telemetry {
|
|
52
|
+
readonly traceId: string;
|
|
53
|
+
generation(event: GenerationEvent): void;
|
|
54
|
+
span(event: SpanEvent): void;
|
|
55
|
+
trace(event: TraceEvent): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TelemetryOptions {
|
|
59
|
+
/** PostHog project API key. Absent → telemetry is a no-op. */
|
|
60
|
+
apiKey?: string;
|
|
61
|
+
/** Ingestion host; defaults to PostHog US cloud. */
|
|
62
|
+
host?: string;
|
|
63
|
+
/** Content capture level; defaults to `full`. */
|
|
64
|
+
captureContent?: CaptureMode;
|
|
65
|
+
/** Distinct id for the person/session; defaults to `anonymous`. */
|
|
66
|
+
distinctId?: string;
|
|
67
|
+
/** Optional label attached to every event as `agent_scope`. */
|
|
68
|
+
scope?: string;
|
|
69
|
+
/** Reuse an existing trace id; one is generated otherwise. */
|
|
70
|
+
traceId?: string;
|
|
71
|
+
/** Cloudflare-style keep-alive so in-flight captures survive response end. */
|
|
72
|
+
waitUntil?: (promise: Promise<unknown>) => void;
|
|
73
|
+
/** Injectable for tests / non-standard runtimes. */
|
|
74
|
+
fetchImpl?: typeof fetch;
|
|
75
|
+
/** Injectable for tests. */
|
|
76
|
+
randomId?: () => string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultRandomId(): string {
|
|
80
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
81
|
+
return c?.randomUUID ? c.randomUUID() : `trace-${Date.now()}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function noopTelemetry(traceId: string): Telemetry {
|
|
85
|
+
return {
|
|
86
|
+
traceId,
|
|
87
|
+
generation() {},
|
|
88
|
+
span() {},
|
|
89
|
+
trace() {},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build a telemetry sink from explicit options. Returns a no-op sink when no
|
|
95
|
+
* PostHog key is set so the agentic path degrades gracefully.
|
|
96
|
+
*/
|
|
97
|
+
export function makeTelemetry(options: TelemetryOptions = {}): Telemetry {
|
|
98
|
+
const randomId = options.randomId ?? defaultRandomId;
|
|
99
|
+
const traceId = options.traceId ?? randomId();
|
|
100
|
+
const { apiKey } = options;
|
|
101
|
+
if (!apiKey) return noopTelemetry(traceId);
|
|
102
|
+
|
|
103
|
+
const host = (options.host ?? 'https://us.i.posthog.com').replace(/\/+$/, '');
|
|
104
|
+
const mode: CaptureMode = options.captureContent ?? 'full';
|
|
105
|
+
const distinctId = options.distinctId ?? 'anonymous';
|
|
106
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
107
|
+
const { waitUntil, scope } = options;
|
|
108
|
+
|
|
109
|
+
const emit = (event: string, properties: Record<string, unknown>) => {
|
|
110
|
+
const body = JSON.stringify({
|
|
111
|
+
api_key: apiKey,
|
|
112
|
+
event,
|
|
113
|
+
distinct_id: distinctId,
|
|
114
|
+
properties: {
|
|
115
|
+
$ai_trace_id: traceId,
|
|
116
|
+
$ai_provider: 'anthropic',
|
|
117
|
+
$process_person_profile: false, // anonymous — no person profile
|
|
118
|
+
...(scope ? { agent_scope: scope } : {}),
|
|
119
|
+
...properties,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const sent = fetchImpl(`${host}/i/v0/e/`, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body,
|
|
126
|
+
})
|
|
127
|
+
.then(() => undefined)
|
|
128
|
+
.catch(() => undefined);
|
|
129
|
+
if (waitUntil) waitUntil(sent);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// For `redacted`, blank out tool_result bodies (the injected document text)
|
|
133
|
+
// while keeping the conversation and tool calls intact.
|
|
134
|
+
const redact = (input: unknown): unknown => {
|
|
135
|
+
if (mode === 'full' || !Array.isArray(input)) return input;
|
|
136
|
+
return input.map((message) => {
|
|
137
|
+
const content = (message as { content?: unknown })?.content;
|
|
138
|
+
if (!Array.isArray(content)) return message;
|
|
139
|
+
return {
|
|
140
|
+
...(message as object),
|
|
141
|
+
content: content.map((block) =>
|
|
142
|
+
(block as { type?: string })?.type === 'tool_result'
|
|
143
|
+
? { ...(block as object), content: '[redacted]' }
|
|
144
|
+
: block,
|
|
145
|
+
),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
traceId,
|
|
152
|
+
generation({ spanId, spanName, model, input, output, usage, latencyMs, httpStatus }) {
|
|
153
|
+
const props: Record<string, unknown> = {
|
|
154
|
+
$ai_span_id: spanId,
|
|
155
|
+
$ai_span_name: spanName,
|
|
156
|
+
$ai_model: model,
|
|
157
|
+
$ai_input_tokens: usage?.input_tokens ?? 0,
|
|
158
|
+
$ai_output_tokens: usage?.output_tokens ?? 0,
|
|
159
|
+
$ai_latency: latencyMs / 1000,
|
|
160
|
+
};
|
|
161
|
+
if (httpStatus !== undefined) {
|
|
162
|
+
props.$ai_http_status = httpStatus;
|
|
163
|
+
props.$ai_is_error = httpStatus >= 400;
|
|
164
|
+
}
|
|
165
|
+
if (mode !== 'off') {
|
|
166
|
+
props.$ai_input = redact(input);
|
|
167
|
+
props.$ai_output_choices = [{ role: 'assistant', content: output }];
|
|
168
|
+
}
|
|
169
|
+
emit('$ai_generation', props);
|
|
170
|
+
},
|
|
171
|
+
span({ spanId, parentId, name, input, output, ok, latencyMs }) {
|
|
172
|
+
const props: Record<string, unknown> = {
|
|
173
|
+
$ai_span_id: spanId,
|
|
174
|
+
$ai_span_name: name,
|
|
175
|
+
$ai_latency: latencyMs / 1000,
|
|
176
|
+
$ai_is_error: !ok,
|
|
177
|
+
};
|
|
178
|
+
if (parentId) props.$ai_parent_id = parentId;
|
|
179
|
+
if (mode !== 'off') {
|
|
180
|
+
if (input !== undefined) props.$ai_input_state = input;
|
|
181
|
+
if (output !== undefined) props.$ai_output_state = output;
|
|
182
|
+
}
|
|
183
|
+
emit('$ai_span', props);
|
|
184
|
+
},
|
|
185
|
+
trace({ name, latencyMs, ok }) {
|
|
186
|
+
emit('$ai_trace', {
|
|
187
|
+
$ai_span_name: name ?? 'hev ask agent',
|
|
188
|
+
$ai_latency: latencyMs / 1000,
|
|
189
|
+
$ai_is_error: !ok,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Read telemetry options from environment variables. `POSTHOG_KEY` (or
|
|
197
|
+
* `POSTHOG_API_KEY`) enables capture; both are checked so this slots into
|
|
198
|
+
* existing PostHog setups. `overrides` win over the environment.
|
|
199
|
+
*/
|
|
200
|
+
export function telemetryFromEnv(
|
|
201
|
+
env: Record<string, string | undefined> = process.env,
|
|
202
|
+
overrides: Partial<TelemetryOptions> = {},
|
|
203
|
+
): TelemetryOptions {
|
|
204
|
+
const raw = env.POSTHOG_CAPTURE_CONTENT?.toLowerCase();
|
|
205
|
+
const captureContent =
|
|
206
|
+
raw === 'off' || raw === 'redacted' || raw === 'full' ? (raw as CaptureMode) : undefined;
|
|
207
|
+
return {
|
|
208
|
+
apiKey: env.POSTHOG_KEY ?? env.POSTHOG_API_KEY,
|
|
209
|
+
host: env.POSTHOG_HOST,
|
|
210
|
+
captureContent,
|
|
211
|
+
...overrides,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import GithubSlugger from 'github-slugger';
|
|
2
|
+
|
|
3
|
+
export interface SourceDocument {
|
|
4
|
+
slug: string;
|
|
5
|
+
title: string;
|
|
6
|
+
group?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Chunk {
|
|
12
|
+
id: string;
|
|
13
|
+
docSlug: string;
|
|
14
|
+
docTitle: string;
|
|
15
|
+
group?: string;
|
|
16
|
+
heading?: string;
|
|
17
|
+
anchorId?: string;
|
|
18
|
+
url: string;
|
|
19
|
+
text: string;
|
|
20
|
+
/** Raw section markdown (pre-clean). Used for verbatim fact extraction; not hashed. */
|
|
21
|
+
raw: string;
|
|
22
|
+
tokens: Set<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SectionDraft {
|
|
26
|
+
heading?: string;
|
|
27
|
+
anchorId?: string;
|
|
28
|
+
lines: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
32
|
+
|
|
33
|
+
export function tokenize(text: string): string[] {
|
|
34
|
+
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Strip Markdown/MDX syntax so excerpts read as prose. Code content is kept
|
|
39
|
+
* for searchability, but fence markers, tags, tables, and link/emphasis syntax
|
|
40
|
+
* are removed.
|
|
41
|
+
*/
|
|
42
|
+
export function cleanMarkdown(src: string): string {
|
|
43
|
+
return src
|
|
44
|
+
.replace(/^\s*(import|export)\s.+$/gm, ' ')
|
|
45
|
+
.replace(/```[a-zA-Z0-9]*\n?/g, ' ')
|
|
46
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
47
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
48
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
49
|
+
.replace(/<\/?[A-Za-z][^>]*>/g, ' ')
|
|
50
|
+
.replace(/^\s*\|?[\s:|-]{3,}\|?\s*$/gm, ' ')
|
|
51
|
+
.replace(/\|/g, ' ')
|
|
52
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, ' ')
|
|
53
|
+
.replace(/^\s{0,3}>\s?/gm, ' ')
|
|
54
|
+
.replace(/^\s{0,3}[-*+]\s+/gm, ' ')
|
|
55
|
+
.replace(/[*_~]{1,3}/g, '')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cleanHeadingText(src: string): string {
|
|
61
|
+
return src
|
|
62
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
63
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
64
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
65
|
+
.replace(/<\/?[A-Za-z][^>]*>/g, ' ')
|
|
66
|
+
.replace(/[*~]{1,3}/g, '')
|
|
67
|
+
.replace(/\s+/g, ' ')
|
|
68
|
+
.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function docSlugToUrl(slug: string, basePath: string): string {
|
|
72
|
+
const base = basePath.endsWith('/') ? basePath : basePath + '/';
|
|
73
|
+
if (slug === 'index') return base.replace(/\/$/, '') || '/';
|
|
74
|
+
return base + slug.replace(/\/index$/, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function chunkDocument(
|
|
78
|
+
doc: SourceDocument,
|
|
79
|
+
basePath: string,
|
|
80
|
+
chunkHeadingDepth = 3,
|
|
81
|
+
): Chunk[] {
|
|
82
|
+
const slugger = new GithubSlugger();
|
|
83
|
+
const sections: SectionDraft[] = [{ lines: [] }];
|
|
84
|
+
let current = sections[0];
|
|
85
|
+
const maxDepth = Math.max(2, Math.min(6, chunkHeadingDepth));
|
|
86
|
+
|
|
87
|
+
for (const line of doc.body.split(/\r?\n/)) {
|
|
88
|
+
const match = line.match(HEADING_RE);
|
|
89
|
+
if (!match) {
|
|
90
|
+
current.lines.push(line);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const level = match[1].length;
|
|
95
|
+
const heading = cleanHeadingText(match[2]);
|
|
96
|
+
const anchorId = slugger.slug(heading);
|
|
97
|
+
|
|
98
|
+
if (level >= 2 && level <= maxDepth) {
|
|
99
|
+
current = { heading, anchorId, lines: [line] };
|
|
100
|
+
sections.push(current);
|
|
101
|
+
} else {
|
|
102
|
+
current.lines.push(line);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return sections
|
|
107
|
+
.map((section, index): Chunk | null => {
|
|
108
|
+
const rawBody = section.lines.join('\n');
|
|
109
|
+
const cleanedBody = cleanMarkdown(rawBody);
|
|
110
|
+
const introPrefix = index === 0 ? [doc.description, cleanedBody] : [cleanedBody];
|
|
111
|
+
const text = introPrefix.filter(Boolean).join('\n').trim();
|
|
112
|
+
if (!text && !section.heading) return null;
|
|
113
|
+
|
|
114
|
+
const url = docSlugToUrl(doc.slug, basePath) + (section.anchorId ? `#${section.anchorId}` : '');
|
|
115
|
+
const id = section.anchorId ? `${doc.slug}#${section.anchorId}` : doc.slug;
|
|
116
|
+
return {
|
|
117
|
+
id,
|
|
118
|
+
docSlug: doc.slug,
|
|
119
|
+
docTitle: doc.title,
|
|
120
|
+
group: doc.group,
|
|
121
|
+
heading: section.heading,
|
|
122
|
+
anchorId: section.anchorId,
|
|
123
|
+
url,
|
|
124
|
+
text,
|
|
125
|
+
raw: rawBody,
|
|
126
|
+
tokens: new Set(tokenize(`${doc.title} ${doc.group ?? ''} ${section.heading ?? ''} ${text}`)),
|
|
127
|
+
};
|
|
128
|
+
})
|
|
129
|
+
.filter((chunk): chunk is Chunk => chunk !== null);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function hashableChunkText(chunks: Pick<Chunk, 'id' | 'text'>[]): string {
|
|
133
|
+
return [...chunks]
|
|
134
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
135
|
+
.map((chunk) => `${chunk.id}\n${chunk.text}`)
|
|
136
|
+
.join('\n---\n');
|
|
137
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getCollection } from 'astro:content';
|
|
2
|
+
import { chunkDocument, type Chunk, type SourceDocument } from './chunk';
|
|
3
|
+
export { prefilter, type Candidate } from './prefilter';
|
|
4
|
+
|
|
5
|
+
export type { Chunk } from './chunk';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads the configured content collections into an in-memory heading chunk
|
|
9
|
+
* index. Built lazily once per server process.
|
|
10
|
+
*/
|
|
11
|
+
export async function buildIndex(
|
|
12
|
+
collections: string[] | null,
|
|
13
|
+
basePath: string,
|
|
14
|
+
chunkHeadingDepth: number,
|
|
15
|
+
): Promise<Chunk[]> {
|
|
16
|
+
if (!collections?.length) {
|
|
17
|
+
throw new Error('[hev-ask] No collections configured. Pass `collections: ["docs"]` to the integration.');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const chunks: Chunk[] = [];
|
|
21
|
+
for (const name of collections) {
|
|
22
|
+
const docs = (await getCollection(name)) as Array<{
|
|
23
|
+
id?: string;
|
|
24
|
+
slug?: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
data?: { title?: string; group?: string; description?: string };
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
for (const doc of docs) {
|
|
30
|
+
const slug = (doc.slug ?? doc.id ?? '').replace(/\.(md|mdx)$/i, '');
|
|
31
|
+
if (!slug) continue;
|
|
32
|
+
const source: SourceDocument = {
|
|
33
|
+
slug,
|
|
34
|
+
title: doc.data?.title ?? slug,
|
|
35
|
+
group: doc.data?.group,
|
|
36
|
+
description: doc.data?.description,
|
|
37
|
+
body: doc.body ?? '',
|
|
38
|
+
};
|
|
39
|
+
chunks.push(...chunkDocument(source, basePath, chunkHeadingDepth));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return chunks.sort((a, b) => a.id.localeCompare(b.id));
|
|
44
|
+
}
|