@bytesbrains/pi-loki-gate 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,84 @@
1
+ # Loki Gate — Agent Usage Guide
2
+
3
+ > You are an AI agent. Use loki-gate tools to query structured logs from Grafana Loki.
4
+ > For complete raw container output (including console.log), use docker-logs tools.
5
+
6
+ ## Quickstart
7
+
8
+ ```bash
9
+ loki_query(query='{service_name="worker"}') # Raw LogQL query
10
+ loki_job_logs(jobId="af57818e") # Logs for a specific job
11
+ loki_worker_logs(worker="1") # Logs from workers
12
+ ```
13
+
14
+ ## Available Labels
15
+
16
+ Loki in wrok.in uses these labels:
17
+
18
+ | Label | Values | Description |
19
+ |-------|--------|-------------|
20
+ | `service_name` | `worker`, `orchestrator`, `unknown` | Which service produced the log |
21
+ | `service` | `worker`, `orchestrator`, `unknown` | Same as service_name |
22
+ | `agent` | `developer`, `code-reviewer`, etc. | Which agent was running |
23
+
24
+ No per-container labels exist — use `docker_worker_logs` for per-container granularity.
25
+
26
+ ## Tools
27
+
28
+ ### loki_query
29
+
30
+ Run an arbitrary LogQL query against Loki. Best for exploring structured application logs.
31
+
32
+ ```bash
33
+ loki_query(query='{service_name="orchestrator"}')
34
+ loki_query(query='{service_name="worker"} |= "error"')
35
+ loki_query(query='{service_name="worker",agent="developer"}', limit=50)
36
+ ```
37
+
38
+ ### loki_job_logs
39
+
40
+ Fetch log lines containing a specific factory job ID. Searches across worker and orchestrator logs.
41
+
42
+ > **Note:** Only finds jobs in structured JSON log lines. Console output ("Job X complete") is only in Docker logs. Use `docker_worker_logs` or `docker_container_logs` for complete job output.
43
+
44
+ ```bash
45
+ loki_job_logs(jobId="af57818e")
46
+ loki_job_logs(jobId="e81f04e9", limit=500)
47
+ ```
48
+
49
+ ### loki_worker_logs
50
+
51
+ Fetch logs from workers or orchestrator via Loki. Filters by `service_name`.
52
+
53
+ ```bash
54
+ loki_worker_logs(worker="1")
55
+ loki_worker_logs(worker="3", search="error")
56
+ loki_worker_logs(container="ai-factory-orchestrator", search="dispatch")
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ `.lokirc.yml`:
62
+
63
+ | Key | Default | Description |
64
+ |-----|---------|-------------|
65
+ | `lokiUrl` | `http://localhost:3100` | Grafana Loki API URL |
66
+ | `defaultLimit` | `100` | Default log lines per query |
67
+
68
+ ## Loki vs Docker
69
+
70
+ | | Loki (`loki_*`) | Docker (`docker_*`) |
71
+ |---|---|---|
72
+ | **Captures** | Structured JSON logs only | All stdout/stderr (console.log + JSON) |
73
+ | **Filtering** | Label-based (agent, service) | Text search (`search` param) |
74
+ | **Per-container** | ❌ No container_name label | ✅ Exact container targeting |
75
+ | **Best for** | Exploring by service/agent, error aggregation | Complete job/worker output, console messages |
76
+ | **Requires** | Loki running (port 3100) | Docker CLI + socket access |
77
+
78
+ ## Troubleshooting
79
+
80
+ | Problem | Solution |
81
+ |---------|----------|
82
+ | `loki_query()` returns nothing | Verify Loki is running: `curl http://localhost:3100/ready` |
83
+ | Labels wrong | Available: `agent`, `service`, `service_name`. Check: `curl http://localhost:3100/loki/api/v1/labels` |
84
+ | Job logs empty | Job ID is likely in console.log, not structured JSON. Use `docker_worker_logs` instead. |
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nandal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # pi-loki-gate
2
+
3
+ Loki log gateway for pi agents — query Grafana Loki for structured application logs from the [wrok.in](https://github.com/nandal/wrok.in) AI Factory.
4
+
5
+ For raw Docker container output (console.log + everything), use [pi-docker-logs](../docker-logs).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:pi-loki-gate
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ Add a `.lokirc.yml` to your repo root:
16
+
17
+ ```yaml
18
+ lokiUrl: http://localhost:3100
19
+ defaultLimit: 100
20
+ ```
21
+
22
+ | Key | Default | Description |
23
+ |-----|---------|-------------|
24
+ | `lokiUrl` | `http://localhost:3100` | Grafana Loki API URL |
25
+ | `defaultLimit` | `100` | Default log lines per query |
26
+
27
+ ## Available Labels
28
+
29
+ | Label | Values | Description |
30
+ |-------|--------|-------------|
31
+ | `service_name` | `worker`, `orchestrator` | Service producing the log |
32
+ | `service` | `worker`, `orchestrator` | Same as service_name |
33
+ | `agent` | `developer`, etc. | Agent running the task |
34
+
35
+ ## Tools
36
+
37
+ ### loki_query
38
+
39
+ Run arbitrary LogQL queries against Loki.
40
+
41
+ ```
42
+ loki_query(query='{service_name="orchestrator"}')
43
+ loki_query(query='{service_name="worker"} |= "error"', limit=50)
44
+ ```
45
+
46
+ ### loki_job_logs
47
+
48
+ Fetch logs for a specific factory job by its ID.
49
+
50
+ > **Note:** Only matches structured JSON log lines. Console output like "Job X complete" is only in Docker logs — use `docker_worker_logs` for full output.
51
+
52
+ ```
53
+ loki_job_logs(jobId="af57818e")
54
+ ```
55
+
56
+ ### loki_worker_logs
57
+
58
+ Fetch logs from workers or orchestrator via Loki.
59
+
60
+ ```
61
+ loki_worker_logs(worker="1")
62
+ loki_worker_logs(worker="3", search="error")
63
+ loki_worker_logs(container="ai-factory-orchestrator")
64
+ ```
65
+
66
+ ## Loki vs Docker Logs
67
+
68
+ | | Loki | Docker Logs |
69
+ |---|---|---|
70
+ | Captures | Structured JSON only | All stdout/stderr |
71
+ | Per-container | ❌ | ✅ |
72
+ | Label filtering | ✅ (agent, service) | ❌ (text search) |
73
+ | Best for | Service/agent exploration, error patterns | Complete job output, console messages |
74
+
75
+ ## License
76
+
77
+ MIT
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@bytesbrains/pi-loki-gate",
3
+ "version": "1.0.0",
4
+ "description": "Loki log gateway for pi agents — query Grafana Loki for container/job/worker logs from the wrok.in AI Factory.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "loki",
9
+ "grafana",
10
+ "logs",
11
+ "logging",
12
+ "ai-factory"
13
+ ],
14
+ "author": "nandal <nandal@users.noreply.github.com>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/nandal/pi-ext.git",
18
+ "directory": "loki-gate"
19
+ },
20
+ "homepage": "https://github.com/nandal/pi-ext/tree/main/loki-gate",
21
+ "bugs": {
22
+ "url": "https://github.com/nandal/pi-ext/issues"
23
+ },
24
+ "license": "MIT",
25
+ "main": "./src/index.ts",
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "files": [
30
+ "src/",
31
+ "README.md",
32
+ "AGENTS.md",
33
+ "LICENSE"
34
+ ],
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "typebox": "*"
38
+ },
39
+ "pi": {
40
+ "extensions": [
41
+ "./src/index.ts"
42
+ ]
43
+ },
44
+ "scripts": {
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^6.0.3",
50
+ "vitest": "^2.1.9"
51
+ }
52
+ }
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { LokiConfig } from "./types";
4
+ import { DEFAULT_CONFIG } from "./types";
5
+
6
+ export function loadConfig(cwd: string): LokiConfig {
7
+ const configPath = path.join(cwd, ".lokirc.yml");
8
+ if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
9
+ try {
10
+ const content = fs.readFileSync(configPath, "utf-8");
11
+ const result: Record<string, unknown> = {};
12
+ for (const line of content.split("\n")) {
13
+ const m = line.match(/^\s*(\w[\w.]*):\s*(.+)$/);
14
+ if (m) {
15
+ let val = m[2].trim();
16
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
17
+ val = val.slice(1, -1);
18
+ }
19
+ result[m[1]] = val;
20
+ }
21
+ }
22
+ return {
23
+ lokiUrl: (result["lokiUrl"] as string) || DEFAULT_CONFIG.lokiUrl,
24
+ defaultLimit: parseInt(result["defaultLimit"] as string) || DEFAULT_CONFIG.defaultLimit,
25
+ };
26
+ } catch {
27
+ return { ...DEFAULT_CONFIG };
28
+ }
29
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { LokiConfig, LokiQueryResult, LokiLogEntry } from "./types";
2
+
3
+ /**
4
+ * Query Loki for log entries using LogQL.
5
+ */
6
+ export async function lokiQuery(
7
+ config: LokiConfig,
8
+ query: string,
9
+ limit: number = config.defaultLimit,
10
+ direction: "forward" | "backward" = "backward",
11
+ ): Promise<{ ok: boolean; entries?: LokiLogEntry[]; error?: string; query: string }> {
12
+ const url = `${config.lokiUrl.replace(/\/$/, "")}/loki/api/v1/query_range`;
13
+ const nowNs = String(Date.now() * 1_000_000);
14
+ const oneHourAgoNs = String((Date.now() - 3600 * 1000) * 1_000_000);
15
+
16
+ const params = new URLSearchParams({
17
+ query,
18
+ start: oneHourAgoNs,
19
+ end: nowNs,
20
+ limit: String(limit),
21
+ direction,
22
+ });
23
+
24
+ try {
25
+ const res = await fetch(`${url}?${params}`, {
26
+ headers: { Accept: "application/json" },
27
+ });
28
+
29
+ if (!res.ok) {
30
+ const text = await res.text().catch(() => "");
31
+ return { ok: false, error: `Loki returned ${res.status}: ${text.slice(0, 200)}`, query };
32
+ }
33
+
34
+ const data = (await res.json()) as LokiQueryResult;
35
+
36
+ if (data.status !== "success") {
37
+ return { ok: false, error: `Loki query failed: ${JSON.stringify(data).slice(0, 200)}`, query };
38
+ }
39
+
40
+ const entries: LokiLogEntry[] = [];
41
+ for (const stream of data.data.result) {
42
+ for (const [tsNs, line] of stream.values) {
43
+ const ts = new Date(parseInt(tsNs) / 1_000_000).toISOString();
44
+ entries.push({ timestamp: ts, line, labels: stream.stream });
45
+ }
46
+ }
47
+
48
+ // Sort by timestamp
49
+ entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
50
+
51
+ return { ok: true, entries, query };
52
+ } catch (err: unknown) {
53
+ const msg = err instanceof Error ? err.message : String(err);
54
+ return { ok: false, error: msg, query };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Format a single log entry for display.
60
+ */
61
+ export function formatLogEntry(entry: LokiLogEntry, showLabels: boolean = false): string {
62
+ const ts = entry.timestamp.slice(0, 19).replace("T", " ");
63
+ const container = entry.labels.service_name || entry.labels.service || "";
64
+ const prefix = container ? `${ts} [${container}]` : ts;
65
+ if (showLabels) {
66
+ const labelStr = Object.entries(entry.labels)
67
+ .map(([k, v]) => `${k}=${v}`)
68
+ .join(", ");
69
+ return `${prefix} ${entry.line} # ${labelStr}`;
70
+ }
71
+ return `${prefix} ${entry.line}`;
72
+ }
73
+
74
+ /**
75
+ * Format log entries with truncation for display.
76
+ */
77
+ export function formatLogOutput(
78
+ entries: LokiLogEntry[],
79
+ maxLines: number,
80
+ showLabels: boolean = false,
81
+ ): string {
82
+ if (entries.length === 0) return "No log entries found.";
83
+
84
+ const total = entries.length;
85
+ const truncated = total > maxLines;
86
+
87
+ let output = entries
88
+ .slice(truncated ? total - maxLines : 0)
89
+ .map((e) => formatLogEntry(e, showLabels))
90
+ .join("\n");
91
+
92
+ if (truncated) {
93
+ output = `... [${total - maxLines} lines truncated] ...\n${output}`;
94
+ }
95
+
96
+ output = `📋 ${total} log entries${truncated ? ` (showing last ${maxLines})` : ""}\n\n${output}`;
97
+ return output;
98
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * pi-loki-gate — Loki Log Gateway
3
+ *
4
+ * Tools: loki_query, loki_job_logs, loki_worker_logs
5
+ * Config: .lokirc.yml
6
+ */
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { queryLogsTool, jobLogsTool, workerLogsTool } from "./tools/query";
9
+
10
+ export default function (pi: ExtensionAPI) {
11
+ pi.registerTool(queryLogsTool);
12
+ pi.registerTool(jobLogsTool);
13
+ pi.registerTool(workerLogsTool);
14
+ }
@@ -0,0 +1,179 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "../config";
4
+ import { lokiQuery, formatLogOutput } from "../helpers";
5
+
6
+ // ─── Query Logs ───────────────────────────────────────────────────────────────
7
+
8
+ export const queryLogsTool = {
9
+ name: "loki_query" as const,
10
+ label: "Query Loki Logs",
11
+ description:
12
+ "Run a LogQL query against the Grafana Loki log aggregator. Returns matching log entries with timestamps and container labels. Available labels: service_name (worker|orchestrator), service, agent. Use for free-form log exploration.",
13
+ parameters: Type.Object({
14
+ query: Type.String({
15
+ description:
16
+ 'LogQL query string (e.g. \'{service_name="worker"}\', \'{service_name="orchestrator"} |= "error"\', etc.)',
17
+ }),
18
+ limit: Type.Optional(Type.Number({ description: "Max log lines to return (default: 100)" })),
19
+ }),
20
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
21
+ const config = loadConfig(ctx.cwd);
22
+ const limit = params.limit || config.defaultLimit;
23
+
24
+ const result = await lokiQuery(config, params.query, limit);
25
+
26
+ if (!result.ok) {
27
+ return {
28
+ content: [
29
+ {
30
+ type: "text",
31
+ text: `❌ Loki query failed: ${result.error}\n Query: ${result.query}`,
32
+ },
33
+ ],
34
+ isError: true,
35
+ details: { query: result.query },
36
+ };
37
+ }
38
+
39
+ const entries = result.entries!;
40
+ const output = formatLogOutput(entries, limit, true);
41
+
42
+ return {
43
+ content: [{ type: "text", text: output }],
44
+ details: { query: result.query, count: entries.length },
45
+ };
46
+ },
47
+ };
48
+
49
+ // ─── Job Logs ──────────────────────────────────────────────────────────────────
50
+
51
+ export const jobLogsTool = {
52
+ name: "loki_job_logs" as const,
53
+ label: "Get Job Logs from Loki",
54
+ description:
55
+ "Fetch logs for a specific wrok.in factory job by searching for its job ID across worker and orchestrator logs. Matches log lines containing the job ID.",
56
+ parameters: Type.Object({
57
+ jobId: Type.String({ description: "Factory job ID (e.g. 'af57818e')" }),
58
+ limit: Type.Optional(Type.Number({ description: "Max log lines (default: 200)" })),
59
+ }),
60
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
61
+ const config = loadConfig(ctx.cwd);
62
+ const limit = params.limit || 200;
63
+ const query = `{service_name=~"worker|orchestrator"} |= \`${params.jobId}\``;
64
+
65
+ const result = await lokiQuery(config, query, limit);
66
+
67
+ if (!result.ok) {
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text",
72
+ text: `❌ Failed to get job logs: ${result.error}\n Job: ${params.jobId}`,
73
+ },
74
+ ],
75
+ isError: true,
76
+ details: { jobId: params.jobId },
77
+ };
78
+ }
79
+
80
+ const entries = result.entries!;
81
+ if (entries.length === 0) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: `No log entries found for job "${params.jobId}".\n\nTry loki_query() with a broader query, or check if Loki is receiving logs.`,
87
+ },
88
+ ],
89
+ details: { jobId: params.jobId, count: 0 },
90
+ };
91
+ }
92
+
93
+ const output = formatLogOutput(entries, limit, false);
94
+
95
+ return {
96
+ content: [{ type: "text", text: output }],
97
+ details: { jobId: params.jobId, count: entries.length },
98
+ };
99
+ },
100
+ };
101
+
102
+ // ─── Worker Logs ───────────────────────────────────────────────────────────────
103
+
104
+ export const workerLogsTool = {
105
+ name: "loki_worker_logs" as const,
106
+ label: "Get Worker Logs from Loki",
107
+ description:
108
+ "Fetch logs from a worker container via Loki. Filters by service_name (worker/orchestrator) and agent. Use docker_worker_logs for per-container granularity.",
109
+ parameters: Type.Object({
110
+ worker: Type.Optional(
111
+ Type.String({
112
+ description: 'Worker number or name (e.g. "1", "worker-1"). Filters to service_name="worker".',
113
+ }),
114
+ ),
115
+ container: Type.Optional(
116
+ Type.String({
117
+ description: "Container name (e.g. 'ai-factory-worker-1', 'ai-factory-orchestrator'). Maps to service_name filter.",
118
+ }),
119
+ ),
120
+ search: Type.Optional(Type.String({ description: "Text to search for in log lines" })),
121
+ limit: Type.Optional(Type.Number({ description: "Max log lines (default: 100)" })),
122
+ }),
123
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
124
+ const config = loadConfig(ctx.cwd);
125
+ const limit = params.limit || config.defaultLimit;
126
+
127
+ // Build service filter (Loki labels: service_name=worker|orchestrator)
128
+ let serviceFilter = `service_name=~"worker|orchestrator"`;
129
+ if (params.container) {
130
+ if (params.container.includes("orchestrator")) {
131
+ serviceFilter = `service_name="orchestrator"`;
132
+ } else if (params.container.includes("worker")) {
133
+ serviceFilter = `service_name="worker"`;
134
+ }
135
+ } else if (params.worker) {
136
+ serviceFilter = `service_name="worker"`;
137
+ }
138
+
139
+ let query = `{${serviceFilter}}`;
140
+ if (params.search) {
141
+ query += ` |= \`${params.search}\``;
142
+ }
143
+
144
+ const result = await lokiQuery(config, query, limit);
145
+
146
+ if (!result.ok) {
147
+ return {
148
+ content: [
149
+ {
150
+ type: "text",
151
+ text: `❌ Failed to get worker logs: ${result.error}\n Query: ${query}`,
152
+ },
153
+ ],
154
+ isError: true,
155
+ details: { query },
156
+ };
157
+ }
158
+
159
+ const entries = result.entries!;
160
+ if (entries.length === 0) {
161
+ return {
162
+ content: [
163
+ {
164
+ type: "text",
165
+ text: `No log entries found for the specified worker.\n Query: ${query}`,
166
+ },
167
+ ],
168
+ details: { query, count: 0 },
169
+ };
170
+ }
171
+
172
+ const output = formatLogOutput(entries, limit, false);
173
+
174
+ return {
175
+ content: [{ type: "text", text: output }],
176
+ details: { query, count: entries.length },
177
+ };
178
+ },
179
+ };
package/src/types.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * pi-loki-gate — Types
3
+ */
4
+
5
+ export interface LokiConfig {
6
+ /** Base URL of the Loki instance (e.g. http://localhost:3100) */
7
+ lokiUrl: string;
8
+ /** Default line limit for queries */
9
+ defaultLimit: number;
10
+ }
11
+
12
+ export const DEFAULT_CONFIG: LokiConfig = {
13
+ lokiUrl: "http://localhost:3100",
14
+ defaultLimit: 100,
15
+ };
16
+
17
+ // ── Loki API Types ──
18
+
19
+ export interface LokiQueryResult {
20
+ status: string;
21
+ data: {
22
+ resultType: string;
23
+ result: LokiStream[];
24
+ };
25
+ }
26
+
27
+ export interface LokiStream {
28
+ stream: Record<string, string>;
29
+ values: Array<[string, string]>; // [timestamp_ns, log_line]
30
+ }
31
+
32
+ export interface LokiLogEntry {
33
+ timestamp: string; // ISO 8601
34
+ line: string;
35
+ labels: Record<string, string>;
36
+ }