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 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
- - `exit`save history and quit
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), plus an optional `knowledge.txt` in the working 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.3')
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
- console.log(chalk.gray(` Model: ${options.model}`));
39
- console.log(chalk.gray(` Knowledge: ${assistant.getResolvedKnowledgePath()}`));
40
- console.log(chalk.gray(` PDF Support: ${options.pdf !== false ? 'Enabled' : 'Disabled'}`));
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(' exit - Save and exit'));
44
- console.log(chalk.gray(' clear - Clear conversation history'));
45
- console.log(chalk.gray(' history - Show number of stored messages'));
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
- process.stdout.write(chalk.cyan('\nAI : '));
94
- const response = await assistant.chat(trimmed);
95
- console.log(chalk.green(response));
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
@@ -91,6 +91,10 @@ ${knowledge}`;
91
91
  return this.knowledge.getResolvedPath();
92
92
  }
93
93
 
94
+ getKnowledgeFiles() {
95
+ return this.knowledge.getLoadedFiles();
96
+ }
97
+
94
98
  async reloadKnowledge() {
95
99
  await this.knowledge.loadKnowledge();
96
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localsage",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "LocalSage — a local, offline AI assistant with PDF & document knowledge. Powered by Qwen via Transformers.js. Pure Node.js, no Python.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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 already inside the folder we're about to scan
34
- // (avoids loading it twice when knowledgePath === cwd).
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
- if (
37
- path.dirname(txtFile) !== this.knowledgePath &&
38
- (await fs.pathExists(txtFile))
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(`Total knowledge loaded: ${this.knowledge.length} characters\n`);
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
- async loadFolder(folderPath) {
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
- for (const file of files) {
77
- const filePath = path.join(folderPath, file);
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
- const ext = path.extname(file).toLowerCase();
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 ${file} ---\n\n${content}`;
93
- console.log(`Loaded: ${file}`);
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: ${file}`);
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 ${file} ---\n\n${content}`;
99
- console.log(`Loaded: ${file}`);
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 ${file}`);
145
+ console.log(`Warning: No text extracted from ${relPath}`);
102
146
  }
103
147
  }
104
148
  } catch (error) {
105
- console.error(`Error reading ${file}:`, error.message);
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
- console.error('Error loading model:', error.message);
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;