localsage 1.0.4 → 1.0.5
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 +4 -3
- package/cli.js +28 -10
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/filetree.js +66 -0
- package/src/knowledge.js +70 -21
- package/src/model.js +17 -2
- package/src/spinner.js +73 -0
package/README.md
CHANGED
|
@@ -44,9 +44,10 @@ The first run downloads the model (~500 MB) to your Hugging Face cache. After th
|
|
|
44
44
|
|
|
45
45
|
## Commands
|
|
46
46
|
|
|
47
|
-
- `
|
|
48
|
-
- `clear` — clear conversation history
|
|
47
|
+
- `/knowledge` — list all loaded knowledge files as a tree
|
|
49
48
|
- `history` — show number of stored messages
|
|
49
|
+
- `clear` — clear conversation history
|
|
50
|
+
- `exit` — save history and quit
|
|
50
51
|
|
|
51
52
|
## Options
|
|
52
53
|
|
|
@@ -103,7 +104,7 @@ main();
|
|
|
103
104
|
|
|
104
105
|
## How It Works
|
|
105
106
|
|
|
106
|
-
1. **Knowledge loading** — reads all TXT, MD, and PDF files from the resolved knowledge folder (explicit `-k` path → `./knowledge` subfolder → current directory),
|
|
107
|
+
1. **Knowledge loading** — recursively reads all TXT, MD, and PDF files from the resolved knowledge folder (explicit `-k` path → `./knowledge` subfolder → current directory), including subfolders, while skipping `node_modules`, `.git`, and other junk directories
|
|
107
108
|
2. **AI processing** — runs the Qwen model locally with `@huggingface/transformers`
|
|
108
109
|
3. **History** — saves the conversation to `history.json` in the working directory
|
|
109
110
|
|
package/cli.js
CHANGED
|
@@ -4,10 +4,12 @@ const chalk = require('chalk');
|
|
|
4
4
|
const inquirer = require('inquirer');
|
|
5
5
|
const { program } = require('commander');
|
|
6
6
|
const LocalSage = require('./index');
|
|
7
|
+
const Spinner = require('./src/spinner');
|
|
8
|
+
const { renderFileTree } = require('./src/filetree');
|
|
7
9
|
|
|
8
10
|
program
|
|
9
11
|
.name('localsage')
|
|
10
|
-
.version('1.0.
|
|
12
|
+
.version('1.0.5')
|
|
11
13
|
.description('LocalSage — local, offline AI assistant with PDF & document knowledge')
|
|
12
14
|
.option('-m, --model <model>', 'Model name', 'onnx-community/Qwen2.5-0.5B-Instruct')
|
|
13
15
|
.option(
|
|
@@ -35,14 +37,15 @@ async function runCLI() {
|
|
|
35
37
|
console.log(chalk.blue('='.repeat(60)));
|
|
36
38
|
console.log(chalk.green.bold(' 🧙 LocalSage — your local AI assistant '));
|
|
37
39
|
console.log(chalk.blue('='.repeat(60)));
|
|
38
|
-
|
|
39
|
-
console.log(chalk.gray(` Knowledge
|
|
40
|
-
console.log(
|
|
40
|
+
const kFiles = assistant.getKnowledgeFiles();
|
|
41
|
+
console.log(chalk.gray(` Knowledge (${kFiles.length} file${kFiles.length === 1 ? '' : 's'}):`));
|
|
42
|
+
console.log(renderFileTree(kFiles, ` ${assistant.getResolvedKnowledgePath()}`));
|
|
41
43
|
console.log(chalk.blue('='.repeat(60)));
|
|
42
44
|
console.log(chalk.gray(' Commands:'));
|
|
43
|
-
console.log(chalk.gray('
|
|
44
|
-
console.log(chalk.gray('
|
|
45
|
-
console.log(chalk.gray('
|
|
45
|
+
console.log(chalk.gray(' /knowledge - List all knowledge files (tree view)'));
|
|
46
|
+
console.log(chalk.gray(' history - Show number of stored messages'));
|
|
47
|
+
console.log(chalk.gray(' clear - Clear conversation history'));
|
|
48
|
+
console.log(chalk.gray(' exit - Save and exit'));
|
|
46
49
|
console.log(chalk.blue('='.repeat(60)));
|
|
47
50
|
console.log(
|
|
48
51
|
chalk.magenta(' 💼 Open to Work — built by a developer available for freelance')
|
|
@@ -85,14 +88,29 @@ async function runCLI() {
|
|
|
85
88
|
continue;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
if (cmd === '/knowledge' || cmd === 'knowledge') {
|
|
92
|
+
const files = assistant.getKnowledgeFiles();
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log(renderFileTree(files, assistant.getResolvedKnowledgePath()));
|
|
95
|
+
console.log(
|
|
96
|
+
chalk.gray(`\n${files.length} file(s) loaded into knowledge\n`)
|
|
97
|
+
);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
if (cmd === 'history') {
|
|
89
102
|
console.log(chalk.yellow(`\nStored Messages : ${assistant.getHistoryCount()}`));
|
|
90
103
|
continue;
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
const spinner = new Spinner('AI is thinking...').start();
|
|
107
|
+
let response;
|
|
108
|
+
try {
|
|
109
|
+
response = await assistant.chat(trimmed);
|
|
110
|
+
} finally {
|
|
111
|
+
spinner.stop();
|
|
112
|
+
}
|
|
113
|
+
console.log(chalk.cyan('AI : ') + chalk.green(response));
|
|
96
114
|
console.log('');
|
|
97
115
|
} catch (error) {
|
|
98
116
|
// inquirer throws when the prompt stream is closed (e.g. Ctrl+C / EOF)
|
package/index.js
CHANGED
package/package.json
CHANGED
package/src/filetree.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a list of relative file paths as an ASCII tree.
|
|
5
|
+
* e.g. ['a.txt', 'docs/b.pdf', 'docs/notes/c.md'] ->
|
|
6
|
+
* ├── a.txt
|
|
7
|
+
* └── docs
|
|
8
|
+
* ├── b.pdf
|
|
9
|
+
* └── notes
|
|
10
|
+
* └── c.md
|
|
11
|
+
*/
|
|
12
|
+
function buildTree(paths) {
|
|
13
|
+
const root = {};
|
|
14
|
+
for (const p of paths) {
|
|
15
|
+
const parts = p.split('/');
|
|
16
|
+
let node = root;
|
|
17
|
+
for (let i = 0; i < parts.length; i++) {
|
|
18
|
+
const part = parts[i];
|
|
19
|
+
const isFile = i === parts.length - 1;
|
|
20
|
+
if (!node[part]) {
|
|
21
|
+
node[part] = { __file: isFile, children: {} };
|
|
22
|
+
}
|
|
23
|
+
node = node[part].children;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderNode(node, prefix, lines) {
|
|
30
|
+
const keys = Object.keys(node).sort((a, b) => {
|
|
31
|
+
// folders first, then files, alphabetical
|
|
32
|
+
const af = node[a].__file ? 1 : 0;
|
|
33
|
+
const bf = node[b].__file ? 1 : 0;
|
|
34
|
+
return af - bf || a.localeCompare(b);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
keys.forEach((key, idx) => {
|
|
38
|
+
const isLast = idx === keys.length - 1;
|
|
39
|
+
const branch = isLast ? '└── ' : '├── ';
|
|
40
|
+
const item = node[key];
|
|
41
|
+
const label = item.__file ? chalk.green(key) : chalk.blue.bold(key + '/');
|
|
42
|
+
lines.push(prefix + chalk.gray(branch) + label);
|
|
43
|
+
if (!item.__file) {
|
|
44
|
+
const childPrefix = prefix + (isLast ? ' ' : chalk.gray('│ '));
|
|
45
|
+
renderNode(item.children, childPrefix, lines);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string[]} paths relative file paths
|
|
52
|
+
* @param {string} rootLabel label for the tree root (e.g. the knowledge dir)
|
|
53
|
+
* @returns {string} printable tree
|
|
54
|
+
*/
|
|
55
|
+
function renderFileTree(paths, rootLabel) {
|
|
56
|
+
const lines = [];
|
|
57
|
+
if (rootLabel) lines.push(chalk.blue.bold(rootLabel));
|
|
58
|
+
if (!paths || paths.length === 0) {
|
|
59
|
+
lines.push(chalk.gray(' (no knowledge files loaded)'));
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
renderNode(buildTree(paths), '', lines);
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { renderFileTree };
|
package/src/knowledge.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
const fs = require('fs-extra');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
+
// Folders that should never be scanned for knowledge
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
'node_modules',
|
|
7
|
+
'.git',
|
|
8
|
+
'.svn',
|
|
9
|
+
'.hg',
|
|
10
|
+
'dist',
|
|
11
|
+
'build',
|
|
12
|
+
'coverage',
|
|
13
|
+
'models',
|
|
14
|
+
'__pycache__',
|
|
15
|
+
'.cache',
|
|
16
|
+
'venv',
|
|
17
|
+
'.venv',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const MAX_DEPTH = 6;
|
|
21
|
+
|
|
4
22
|
class KnowledgeManager {
|
|
5
23
|
/**
|
|
6
24
|
* @param {string|null} knowledgePath - explicit path from the user, or null
|
|
@@ -11,6 +29,7 @@ class KnowledgeManager {
|
|
|
11
29
|
this.requestedPath = knowledgePath;
|
|
12
30
|
this.knowledgePath = null; // resolved in loadKnowledge()
|
|
13
31
|
this.knowledge = '';
|
|
32
|
+
this.loadedFiles = []; // relative paths of every file loaded
|
|
14
33
|
}
|
|
15
34
|
|
|
16
35
|
resolvePath() {
|
|
@@ -27,18 +46,20 @@ class KnowledgeManager {
|
|
|
27
46
|
|
|
28
47
|
async loadKnowledge() {
|
|
29
48
|
this.knowledgePath = this.resolvePath();
|
|
49
|
+
this.loadedFiles = [];
|
|
30
50
|
let combinedKnowledge = '';
|
|
31
51
|
|
|
32
52
|
// Load a standalone knowledge.txt from the working directory —
|
|
33
|
-
// but only if it isn't
|
|
34
|
-
// (avoids loading it twice when knowledgePath
|
|
53
|
+
// but only if it isn't inside the tree we're about to scan
|
|
54
|
+
// (avoids loading it twice when knowledgePath covers cwd).
|
|
35
55
|
const txtFile = path.join(process.cwd(), 'knowledge.txt');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
) {
|
|
56
|
+
const insideTree = !path
|
|
57
|
+
.relative(this.knowledgePath, txtFile)
|
|
58
|
+
.startsWith('..');
|
|
59
|
+
if (!insideTree && (await fs.pathExists(txtFile))) {
|
|
40
60
|
try {
|
|
41
61
|
combinedKnowledge += await fs.readFile(txtFile, 'utf-8');
|
|
62
|
+
this.loadedFiles.push('knowledge.txt (working directory)');
|
|
42
63
|
console.log('Knowledge loaded from knowledge.txt');
|
|
43
64
|
} catch (error) {
|
|
44
65
|
console.error('Error loading knowledge.txt:', error.message);
|
|
@@ -47,7 +68,7 @@ class KnowledgeManager {
|
|
|
47
68
|
|
|
48
69
|
if (await fs.pathExists(this.knowledgePath)) {
|
|
49
70
|
console.log(`Loading knowledge from: ${this.knowledgePath}`);
|
|
50
|
-
const folderKnowledge = await this.loadFolder(this.knowledgePath);
|
|
71
|
+
const folderKnowledge = await this.loadFolder(this.knowledgePath, '', 0);
|
|
51
72
|
if (folderKnowledge) {
|
|
52
73
|
if (combinedKnowledge) {
|
|
53
74
|
combinedKnowledge += '\n\n' + '='.repeat(50) + '\n\n';
|
|
@@ -63,18 +84,34 @@ class KnowledgeManager {
|
|
|
63
84
|
if (!this.knowledge.trim()) {
|
|
64
85
|
console.log('Warning: No knowledge loaded. Using general knowledge only.\n');
|
|
65
86
|
} else {
|
|
66
|
-
console.log(
|
|
87
|
+
console.log(
|
|
88
|
+
`Total knowledge loaded: ${this.loadedFiles.length} file(s), ${this.knowledge.length} characters\n`
|
|
89
|
+
);
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
return this.knowledge;
|
|
70
93
|
}
|
|
71
94
|
|
|
72
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Recursively load .txt/.md/.pdf files.
|
|
97
|
+
* @param {string} folderPath absolute folder to scan
|
|
98
|
+
* @param {string} relBase path of folderPath relative to knowledge root
|
|
99
|
+
* @param {number} depth current recursion depth
|
|
100
|
+
*/
|
|
101
|
+
async loadFolder(folderPath, relBase, depth) {
|
|
102
|
+
if (depth > MAX_DEPTH) return '';
|
|
73
103
|
let combined = '';
|
|
74
|
-
const files = await fs.readdir(folderPath);
|
|
75
104
|
|
|
76
|
-
|
|
77
|
-
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = await fs.readdir(folderPath);
|
|
108
|
+
} catch {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const entry of entries.sort()) {
|
|
113
|
+
const filePath = path.join(folderPath, entry);
|
|
114
|
+
const relPath = relBase ? `${relBase}/${entry}` : entry;
|
|
78
115
|
|
|
79
116
|
let stat;
|
|
80
117
|
try {
|
|
@@ -82,27 +119,34 @@ class KnowledgeManager {
|
|
|
82
119
|
} catch {
|
|
83
120
|
continue;
|
|
84
121
|
}
|
|
85
|
-
if (stat.isDirectory()) continue;
|
|
86
122
|
|
|
87
|
-
|
|
123
|
+
if (stat.isDirectory()) {
|
|
124
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue;
|
|
125
|
+
combined += await this.loadFolder(filePath, relPath, depth + 1);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const ext = path.extname(entry).toLowerCase();
|
|
88
130
|
|
|
89
131
|
try {
|
|
90
132
|
if (ext === '.txt' || ext === '.md') {
|
|
91
133
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
92
|
-
combined += `\n\n--- Content from ${
|
|
93
|
-
|
|
134
|
+
combined += `\n\n--- Content from ${relPath} ---\n\n${content}`;
|
|
135
|
+
this.loadedFiles.push(relPath);
|
|
136
|
+
console.log(`Loaded: ${relPath}`);
|
|
94
137
|
} else if (ext === '.pdf') {
|
|
95
|
-
console.log(`Reading PDF: ${
|
|
138
|
+
console.log(`Reading PDF: ${relPath}`);
|
|
96
139
|
const content = await this.extractPDF(filePath);
|
|
97
140
|
if (content.trim()) {
|
|
98
|
-
combined += `\n\n--- Content from ${
|
|
99
|
-
|
|
141
|
+
combined += `\n\n--- Content from ${relPath} ---\n\n${content}`;
|
|
142
|
+
this.loadedFiles.push(relPath);
|
|
143
|
+
console.log(`Loaded: ${relPath}`);
|
|
100
144
|
} else {
|
|
101
|
-
console.log(`Warning: No text extracted from ${
|
|
145
|
+
console.log(`Warning: No text extracted from ${relPath}`);
|
|
102
146
|
}
|
|
103
147
|
}
|
|
104
148
|
} catch (error) {
|
|
105
|
-
console.error(`Error reading ${
|
|
149
|
+
console.error(`Error reading ${relPath}:`, error.message);
|
|
106
150
|
}
|
|
107
151
|
}
|
|
108
152
|
|
|
@@ -125,6 +169,11 @@ class KnowledgeManager {
|
|
|
125
169
|
return this.knowledgePath || this.resolvePath();
|
|
126
170
|
}
|
|
127
171
|
|
|
172
|
+
/** Relative paths of every file that was loaded into the knowledge. */
|
|
173
|
+
getLoadedFiles() {
|
|
174
|
+
return this.loadedFiles;
|
|
175
|
+
}
|
|
176
|
+
|
|
128
177
|
getKnowledge(truncate = true, maxLength = 4000) {
|
|
129
178
|
if (!truncate || this.knowledge.length <= maxLength) {
|
|
130
179
|
return this.knowledge;
|
package/src/model.js
CHANGED
|
@@ -28,6 +28,9 @@ class QwenModel {
|
|
|
28
28
|
|
|
29
29
|
console.log('Loading AI Model (first run downloads ~500MB, please wait)...');
|
|
30
30
|
|
|
31
|
+
const Spinner = require('./spinner');
|
|
32
|
+
const spinner = new Spinner('Preparing model...').start();
|
|
33
|
+
|
|
31
34
|
try {
|
|
32
35
|
const { pipeline, env } = await loadTransformers();
|
|
33
36
|
|
|
@@ -37,14 +40,26 @@ class QwenModel {
|
|
|
37
40
|
env.cacheDir = path.resolve(process.env.LOCALSAGE_MODEL_DIR);
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
const barWidth = 20;
|
|
40
44
|
this.pipeline = await pipeline('text-generation', this.modelName, {
|
|
41
45
|
dtype: 'q4', // quantized for speed / low RAM
|
|
46
|
+
progress_callback: (info) => {
|
|
47
|
+
if (info.status === 'progress' && typeof info.progress === 'number') {
|
|
48
|
+
const pct = Math.min(100, info.progress);
|
|
49
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
50
|
+
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
51
|
+
const file = (info.file || '').split('/').pop();
|
|
52
|
+
spinner.update(`Downloading ${file} [${bar}] ${pct.toFixed(1)}%`);
|
|
53
|
+
} else if (info.status === 'ready') {
|
|
54
|
+
spinner.update('Starting model...');
|
|
55
|
+
}
|
|
56
|
+
},
|
|
42
57
|
});
|
|
43
58
|
|
|
59
|
+
spinner.succeed('✓ Model Loaded Successfully.\n');
|
|
44
60
|
this.isLoaded = true;
|
|
45
|
-
console.log('Model Loaded Successfully.\n');
|
|
46
61
|
} catch (error) {
|
|
47
|
-
|
|
62
|
+
spinner.fail(`Error loading model: ${error.message}`);
|
|
48
63
|
throw error;
|
|
49
64
|
}
|
|
50
65
|
}
|
package/src/spinner.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny terminal spinner — no external dependencies.
|
|
5
|
+
* Falls back to a single static line when not running in a TTY
|
|
6
|
+
* (so piped/CI output doesn't fill with animation frames).
|
|
7
|
+
*/
|
|
8
|
+
class Spinner {
|
|
9
|
+
constructor(text = 'Loading...') {
|
|
10
|
+
this.text = text;
|
|
11
|
+
this.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
12
|
+
this.interval = null;
|
|
13
|
+
this.frameIndex = 0;
|
|
14
|
+
this.isTTY = Boolean(process.stdout.isTTY);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
start() {
|
|
18
|
+
if (!this.isTTY) {
|
|
19
|
+
process.stdout.write(chalk.cyan(`${this.text}\n`));
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
this.frameIndex = 0;
|
|
23
|
+
this.render();
|
|
24
|
+
this.interval = setInterval(() => this.render(), 80);
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
const frame = this.frames[this.frameIndex];
|
|
30
|
+
this.frameIndex = (this.frameIndex + 1) % this.frames.length;
|
|
31
|
+
this.clearLine();
|
|
32
|
+
process.stdout.write(chalk.cyan(`${frame} ${this.text}`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Update the message while spinning (e.g. download progress). */
|
|
36
|
+
update(text) {
|
|
37
|
+
this.text = text;
|
|
38
|
+
if (!this.isTTY) return this;
|
|
39
|
+
if (this.interval) this.render();
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clearLine() {
|
|
44
|
+
if (!this.isTTY) return;
|
|
45
|
+
process.stdout.clearLine(0);
|
|
46
|
+
process.stdout.cursorTo(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Stop and remove the spinner line entirely. */
|
|
50
|
+
stop() {
|
|
51
|
+
if (this.interval) {
|
|
52
|
+
clearInterval(this.interval);
|
|
53
|
+
this.interval = null;
|
|
54
|
+
}
|
|
55
|
+
this.clearLine();
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Stop and replace the spinner line with a final message. */
|
|
60
|
+
succeed(text) {
|
|
61
|
+
this.stop();
|
|
62
|
+
if (text) console.log(chalk.green(text));
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fail(text) {
|
|
67
|
+
this.stop();
|
|
68
|
+
if (text) console.log(chalk.red(text));
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = Spinner;
|