tlc-claude-code 1.8.5 → 2.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/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding Client — Ollama-based text embedding with graceful degradation.
|
|
3
|
+
*
|
|
4
|
+
* Generates vector embeddings from text using a local Ollama instance.
|
|
5
|
+
* Returns null when Ollama is unavailable (caller falls back to text search).
|
|
6
|
+
*
|
|
7
|
+
* @module embedding-client
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Max characters to send per embed request (~8192 tokens * 4 chars/token) */
|
|
11
|
+
const MAX_INPUT_CHARS = 32768;
|
|
12
|
+
|
|
13
|
+
/** Known model dimensions */
|
|
14
|
+
const MODEL_DIMENSIONS = {
|
|
15
|
+
'mxbai-embed-large': 1024,
|
|
16
|
+
'nomic-embed-text': 768,
|
|
17
|
+
'all-minilm': 384,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create an embedding client that talks to Ollama.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {string} [options.host='http://localhost:11434'] - Ollama host URL
|
|
25
|
+
* @param {string} [options.model='mxbai-embed-large'] - Embedding model name
|
|
26
|
+
* @param {number} [options.timeout=30000] - Request timeout in ms
|
|
27
|
+
* @returns {object} Client with embed/embedBatch/isAvailable/getModelInfo
|
|
28
|
+
*/
|
|
29
|
+
export function createEmbeddingClient(options = {}) {
|
|
30
|
+
const host = options.host || 'http://localhost:11434';
|
|
31
|
+
const model = options.model || 'mxbai-embed-large';
|
|
32
|
+
const timeout = options.timeout || 30000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Truncate text to fit within model token limits.
|
|
36
|
+
*/
|
|
37
|
+
function truncateText(text) {
|
|
38
|
+
if (text.length > MAX_INPUT_CHARS) {
|
|
39
|
+
return text.slice(0, MAX_INPUT_CHARS);
|
|
40
|
+
}
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Embed a single text string.
|
|
46
|
+
* @param {string} text
|
|
47
|
+
* @returns {Promise<Float32Array|null>} Embedding or null if unavailable
|
|
48
|
+
*/
|
|
49
|
+
async function embed(text) {
|
|
50
|
+
if (!text || text.length === 0) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const input = truncateText(text);
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
58
|
+
|
|
59
|
+
const response = await fetch(`${host}/api/embed`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ model, input }),
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
clearTimeout(timeoutId);
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const data = await response.json();
|
|
73
|
+
if (!data.embeddings || data.embeddings.length === 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return new Float32Array(data.embeddings[0]);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Embed multiple texts in a single batch request.
|
|
85
|
+
* @param {string[]} texts
|
|
86
|
+
* @returns {Promise<(Float32Array|null)[]>}
|
|
87
|
+
*/
|
|
88
|
+
async function embedBatch(texts) {
|
|
89
|
+
if (!texts || texts.length === 0) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const inputs = texts.map(truncateText);
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
97
|
+
|
|
98
|
+
const response = await fetch(`${host}/api/embed`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ model, input: inputs }),
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
clearTimeout(timeoutId);
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
return texts.map(() => null);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const data = await response.json();
|
|
112
|
+
if (!data.embeddings) {
|
|
113
|
+
return texts.map(() => null);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return data.embeddings.map((emb) => new Float32Array(emb));
|
|
117
|
+
} catch {
|
|
118
|
+
return texts.map(() => null);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if Ollama is running and accessible.
|
|
124
|
+
* @returns {Promise<boolean>}
|
|
125
|
+
*/
|
|
126
|
+
async function isAvailable() {
|
|
127
|
+
try {
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
130
|
+
|
|
131
|
+
const response = await fetch(`${host}/api/tags`, {
|
|
132
|
+
signal: controller.signal,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
clearTimeout(timeoutId);
|
|
136
|
+
|
|
137
|
+
return response.ok;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get info about the configured embedding model.
|
|
145
|
+
* @returns {{ model: string, dimensions: number }}
|
|
146
|
+
*/
|
|
147
|
+
function getModelInfo() {
|
|
148
|
+
return {
|
|
149
|
+
model,
|
|
150
|
+
dimensions: MODEL_DIMENSIONS[model] || 1024,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
embed,
|
|
156
|
+
embedBatch,
|
|
157
|
+
isAvailable,
|
|
158
|
+
getModelInfo,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding Client Tests
|
|
3
|
+
* Tests for Ollama-based embedding client with graceful degradation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
+
import { createEmbeddingClient } from './embedding-client.js';
|
|
8
|
+
|
|
9
|
+
describe('embedding-client', () => {
|
|
10
|
+
let client;
|
|
11
|
+
let originalFetch;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
originalFetch = global.fetch;
|
|
15
|
+
global.fetch = vi.fn();
|
|
16
|
+
client = createEmbeddingClient();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
global.fetch = originalFetch;
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('embed', () => {
|
|
25
|
+
it('embeds text and returns Float32Array', async () => {
|
|
26
|
+
const mockEmbedding = Array.from({ length: 1024 }, (_, i) => i * 0.001);
|
|
27
|
+
|
|
28
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
29
|
+
ok: true,
|
|
30
|
+
json: () => Promise.resolve({
|
|
31
|
+
embeddings: [mockEmbedding],
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const result = await client.embed('hello world');
|
|
36
|
+
|
|
37
|
+
expect(result).toBeInstanceOf(Float32Array);
|
|
38
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
39
|
+
'http://localhost:11434/api/embed',
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
method: 'POST',
|
|
42
|
+
body: expect.any(String),
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const body = JSON.parse(global.fetch.mock.calls[0][1].body);
|
|
47
|
+
expect(body.model).toBe('mxbai-embed-large');
|
|
48
|
+
expect(body.input).toBe('hello world');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns correct dimensions for mxbai-embed-large (1024)', async () => {
|
|
52
|
+
const mockEmbedding = Array.from({ length: 1024 }, () => Math.random());
|
|
53
|
+
|
|
54
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
55
|
+
ok: true,
|
|
56
|
+
json: () => Promise.resolve({
|
|
57
|
+
embeddings: [mockEmbedding],
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await client.embed('test text');
|
|
62
|
+
|
|
63
|
+
expect(result).toBeInstanceOf(Float32Array);
|
|
64
|
+
expect(result.length).toBe(1024);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns null on connection failure (graceful degradation)', async () => {
|
|
68
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
69
|
+
|
|
70
|
+
const result = await client.embed('some text');
|
|
71
|
+
|
|
72
|
+
expect(result).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns null for empty text', async () => {
|
|
76
|
+
const result = await client.embed('');
|
|
77
|
+
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('truncates text exceeding token limit (8192 tokens ~ 32768 chars)', async () => {
|
|
83
|
+
const longText = 'a'.repeat(40000);
|
|
84
|
+
const mockEmbedding = Array.from({ length: 1024 }, () => 0.5);
|
|
85
|
+
|
|
86
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
87
|
+
ok: true,
|
|
88
|
+
json: () => Promise.resolve({
|
|
89
|
+
embeddings: [mockEmbedding],
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await client.embed(longText);
|
|
94
|
+
|
|
95
|
+
expect(global.fetch).toHaveBeenCalled();
|
|
96
|
+
const body = JSON.parse(global.fetch.mock.calls[0][1].body);
|
|
97
|
+
expect(body.input.length).toBeLessThanOrEqual(32768);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('handles Ollama API error responses', async () => {
|
|
101
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
102
|
+
ok: false,
|
|
103
|
+
status: 500,
|
|
104
|
+
json: () => Promise.resolve({ error: 'model not found' }),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await client.embed('test');
|
|
108
|
+
|
|
109
|
+
expect(result).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('times out after 30s default', async () => {
|
|
113
|
+
global.fetch = vi.fn().mockImplementation((url, opts) => {
|
|
114
|
+
// Verify that an AbortSignal is passed for timeout control
|
|
115
|
+
expect(opts.signal).toBeDefined();
|
|
116
|
+
return new Promise((_, reject) => {
|
|
117
|
+
const error = new Error('The operation was aborted');
|
|
118
|
+
error.name = 'AbortError';
|
|
119
|
+
reject(error);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = await client.embed('test');
|
|
124
|
+
|
|
125
|
+
expect(result).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('embedBatch', () => {
|
|
130
|
+
it('batch embed processes multiple texts', async () => {
|
|
131
|
+
const mockEmbedding1 = Array.from({ length: 1024 }, () => 0.1);
|
|
132
|
+
const mockEmbedding2 = Array.from({ length: 1024 }, () => 0.2);
|
|
133
|
+
const mockEmbedding3 = Array.from({ length: 1024 }, () => 0.3);
|
|
134
|
+
|
|
135
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
136
|
+
ok: true,
|
|
137
|
+
json: () => Promise.resolve({
|
|
138
|
+
embeddings: [mockEmbedding1, mockEmbedding2, mockEmbedding3],
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const results = await client.embedBatch(['text one', 'text two', 'text three']);
|
|
143
|
+
|
|
144
|
+
expect(results).toHaveLength(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('batch embed returns array of Float32Arrays', async () => {
|
|
148
|
+
const mockEmbedding1 = Array.from({ length: 1024 }, () => 0.1);
|
|
149
|
+
const mockEmbedding2 = Array.from({ length: 1024 }, () => 0.2);
|
|
150
|
+
|
|
151
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
152
|
+
ok: true,
|
|
153
|
+
json: () => Promise.resolve({
|
|
154
|
+
embeddings: [mockEmbedding1, mockEmbedding2],
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const results = await client.embedBatch(['hello', 'world']);
|
|
159
|
+
|
|
160
|
+
expect(results).toHaveLength(2);
|
|
161
|
+
results.forEach(result => {
|
|
162
|
+
expect(result).toBeInstanceOf(Float32Array);
|
|
163
|
+
expect(result.length).toBe(1024);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('isAvailable', () => {
|
|
169
|
+
it('returns true when Ollama is running', async () => {
|
|
170
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
171
|
+
ok: true,
|
|
172
|
+
json: () => Promise.resolve({
|
|
173
|
+
models: [{ name: 'mxbai-embed-large', size: 670000000 }],
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const available = await client.isAvailable();
|
|
178
|
+
|
|
179
|
+
expect(available).toBe(true);
|
|
180
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
181
|
+
'http://localhost:11434/api/tags',
|
|
182
|
+
expect.any(Object)
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns false when Ollama is not running', async () => {
|
|
187
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
|
188
|
+
|
|
189
|
+
const available = await client.isAvailable();
|
|
190
|
+
|
|
191
|
+
expect(available).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('getModelInfo', () => {
|
|
196
|
+
it('returns model name and dimensions', () => {
|
|
197
|
+
const info = client.getModelInfo();
|
|
198
|
+
|
|
199
|
+
expect(info).toEqual({
|
|
200
|
+
model: 'mxbai-embed-large',
|
|
201
|
+
dimensions: 1024,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('configuration', () => {
|
|
207
|
+
it('respects configurable model name', async () => {
|
|
208
|
+
const customClient = createEmbeddingClient({ model: 'nomic-embed-text' });
|
|
209
|
+
const mockEmbedding = Array.from({ length: 768 }, () => 0.5);
|
|
210
|
+
|
|
211
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
212
|
+
ok: true,
|
|
213
|
+
json: () => Promise.resolve({
|
|
214
|
+
embeddings: [mockEmbedding],
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
await customClient.embed('test');
|
|
219
|
+
|
|
220
|
+
const body = JSON.parse(global.fetch.mock.calls[0][1].body);
|
|
221
|
+
expect(body.model).toBe('nomic-embed-text');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('respects configurable host', async () => {
|
|
225
|
+
const customClient = createEmbeddingClient({ host: 'http://ollama.local:11434' });
|
|
226
|
+
const mockEmbedding = Array.from({ length: 1024 }, () => 0.5);
|
|
227
|
+
|
|
228
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
229
|
+
ok: true,
|
|
230
|
+
json: () => Promise.resolve({
|
|
231
|
+
embeddings: [mockEmbedding],
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await customClient.embed('test');
|
|
236
|
+
|
|
237
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
238
|
+
'http://ollama.local:11434/api/embed',
|
|
239
|
+
expect.any(Object)
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global TLC Configuration - Persistent config at ~/.tlc/config.json
|
|
3
|
+
*
|
|
4
|
+
* Stores workspace root paths and settings that survive reinstalls.
|
|
5
|
+
* XDG-aware: uses $TLC_CONFIG_DIR or defaults to ~/.tlc/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
const CONFIG_FILENAME = 'config.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get the config directory path
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function getConfigDir() {
|
|
19
|
+
if (process.env.TLC_CONFIG_DIR) {
|
|
20
|
+
return process.env.TLC_CONFIG_DIR;
|
|
21
|
+
}
|
|
22
|
+
return path.join(os.homedir(), '.tlc');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default config structure
|
|
27
|
+
* @returns {object}
|
|
28
|
+
*/
|
|
29
|
+
function defaultConfig() {
|
|
30
|
+
return {
|
|
31
|
+
version: 1,
|
|
32
|
+
roots: [],
|
|
33
|
+
scanDepth: 5,
|
|
34
|
+
lastScans: {},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class GlobalConfig {
|
|
39
|
+
constructor() {
|
|
40
|
+
this.configDir = getConfigDir();
|
|
41
|
+
this.configPath = path.join(this.configDir, CONFIG_FILENAME);
|
|
42
|
+
this._config = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load config from disk, creating defaults if needed
|
|
47
|
+
* @returns {object} The config object
|
|
48
|
+
*/
|
|
49
|
+
load() {
|
|
50
|
+
this._ensureDir();
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(this.configPath)) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = fs.readFileSync(this.configPath, 'utf-8');
|
|
55
|
+
this._config = JSON.parse(raw);
|
|
56
|
+
return this._config;
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Corrupted JSON — reset to defaults
|
|
59
|
+
console.error('Corrupted config, resetting to defaults:', err.message);
|
|
60
|
+
this._config = defaultConfig();
|
|
61
|
+
this._save();
|
|
62
|
+
return this._config;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// First access — create defaults
|
|
67
|
+
this._config = defaultConfig();
|
|
68
|
+
this._save();
|
|
69
|
+
return this._config;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get all configured root paths
|
|
74
|
+
* @returns {string[]}
|
|
75
|
+
*/
|
|
76
|
+
getRoots() {
|
|
77
|
+
this._ensureLoaded();
|
|
78
|
+
return [...this._config.roots];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Add a root directory path
|
|
83
|
+
* @param {string} rootPath - Absolute path to a directory
|
|
84
|
+
* @throws {Error} If path is invalid
|
|
85
|
+
*/
|
|
86
|
+
addRoot(rootPath) {
|
|
87
|
+
this._ensureLoaded();
|
|
88
|
+
|
|
89
|
+
const resolved = path.resolve(rootPath);
|
|
90
|
+
|
|
91
|
+
if (!fs.existsSync(resolved)) {
|
|
92
|
+
throw new Error(`Path does not exist: ${resolved}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const stat = fs.statSync(resolved);
|
|
96
|
+
if (!stat.isDirectory()) {
|
|
97
|
+
throw new Error(`Path is not a directory: ${resolved}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (this._config.roots.includes(resolved)) {
|
|
101
|
+
throw new Error(`Root already configured: ${resolved}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this._config.roots.push(resolved);
|
|
105
|
+
this._save();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove a root directory path
|
|
110
|
+
* @param {string} rootPath - Path to remove
|
|
111
|
+
*/
|
|
112
|
+
removeRoot(rootPath) {
|
|
113
|
+
this._ensureLoaded();
|
|
114
|
+
|
|
115
|
+
const resolved = path.resolve(rootPath);
|
|
116
|
+
this._config.roots = this._config.roots.filter((r) => r !== resolved);
|
|
117
|
+
|
|
118
|
+
// Clean up lastScans entry
|
|
119
|
+
delete this._config.lastScans[resolved];
|
|
120
|
+
|
|
121
|
+
this._save();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if any roots are configured
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
isConfigured() {
|
|
129
|
+
this._ensureLoaded();
|
|
130
|
+
return this._config.roots.length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Set scan depth
|
|
135
|
+
* @param {number} depth
|
|
136
|
+
*/
|
|
137
|
+
setScanDepth(depth) {
|
|
138
|
+
this._ensureLoaded();
|
|
139
|
+
this._config.scanDepth = depth;
|
|
140
|
+
this._save();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Set last scan timestamp for a root
|
|
145
|
+
* @param {string} rootPath
|
|
146
|
+
* @param {number} timestamp
|
|
147
|
+
*/
|
|
148
|
+
setLastScan(rootPath, timestamp) {
|
|
149
|
+
this._ensureLoaded();
|
|
150
|
+
const resolved = path.resolve(rootPath);
|
|
151
|
+
this._config.lastScans[resolved] = timestamp;
|
|
152
|
+
this._save();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get last scan timestamp for a root
|
|
157
|
+
* @param {string} rootPath
|
|
158
|
+
* @returns {number|null}
|
|
159
|
+
*/
|
|
160
|
+
getLastScan(rootPath) {
|
|
161
|
+
this._ensureLoaded();
|
|
162
|
+
const resolved = path.resolve(rootPath);
|
|
163
|
+
return this._config.lastScans[resolved] || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Ensure config directory exists
|
|
168
|
+
* @private
|
|
169
|
+
*/
|
|
170
|
+
_ensureDir() {
|
|
171
|
+
if (!fs.existsSync(this.configDir)) {
|
|
172
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Ensure config is loaded
|
|
178
|
+
* @private
|
|
179
|
+
*/
|
|
180
|
+
_ensureLoaded() {
|
|
181
|
+
if (!this._config) {
|
|
182
|
+
this.load();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Atomic write to config file
|
|
188
|
+
* @private
|
|
189
|
+
*/
|
|
190
|
+
_save() {
|
|
191
|
+
this._ensureDir();
|
|
192
|
+
const tmpPath = this.configPath + '.tmp';
|
|
193
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this._config, null, 2), 'utf-8');
|
|
194
|
+
fs.renameSync(tmpPath, this.configPath);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { GlobalConfig };
|