terminal-ai-helper 2.1.0 → 2.3.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/VERSION +1 -1
- package/package.json +1 -1
- package/plugins/bash/index.bash +2 -2
- package/plugins/bash/keys.bash +15 -2
- package/plugins/bash/lookup.bash +54 -0
- package/plugins/zsh/index.zsh +2 -2
- package/plugins/zsh/lookup.zsh +84 -0
- package/plugins/zsh/menu.zsh +13 -1
- package/plugins/zsh/widgets.zsh +17 -0
- package/readme.md +14 -2
- package/tai/index.py +30 -12
- package/tai/paths.py +55 -0
- package/tai/predictor.py +20 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.3.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminal-ai-helper",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "The terminal that knows your next command Bash/Zsh suggestions that learn from your own history Offline and private with no account required",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/plugins/bash/index.bash
CHANGED
|
@@ -24,7 +24,7 @@ _TAI_INDEX_STAMP="${_TAI_INDEX_FILE}.stamp"
|
|
|
24
24
|
# prints "value too great for base" on every keystroke instead of looking
|
|
25
25
|
# anything up. A shell started before the first `tai refresh` still has to
|
|
26
26
|
# be quiet and still gets the installed-command fallback.
|
|
27
|
-
declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE
|
|
27
|
+
declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE
|
|
28
28
|
|
|
29
29
|
# Is this file the index, or something that only looks like it?
|
|
30
30
|
#
|
|
@@ -69,7 +69,7 @@ _tai_load_index() {
|
|
|
69
69
|
# installed for the life of the shell — the same rule as plugins/tai.zsh, and
|
|
70
70
|
# for the same reason: the file has to be the whole truth rather than a growing
|
|
71
71
|
# superset of every index this shell has ever read.
|
|
72
|
-
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=()
|
|
72
|
+
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=() _TAI_LIVE=()
|
|
73
73
|
# The sorted-keys array is built on first use, not here: its one fork (a
|
|
74
74
|
# `sort` over every first-word key) belonged to the shell's startup, which
|
|
75
75
|
# is exactly the cost a plugin should not add — and most keystrokes never
|
package/plugins/bash/keys.bash
CHANGED
|
@@ -201,7 +201,12 @@ _tai_complete() {
|
|
|
201
201
|
for c in "${_TAI_LEARNED_LINES[@]}"; do
|
|
202
202
|
# Folded, like the lookup that produced them: `ls down` offers
|
|
203
203
|
# `ls Downloads`.
|
|
204
|
-
[[ "${c,,}" == "${line,,}"* ]]
|
|
204
|
+
[[ "${c,,}" == "${line,,}"* ]] || continue
|
|
205
|
+
# A whole learned line is committed whole, so every word in it that the
|
|
206
|
+
# index marked as a relative path has to resolve here — the same
|
|
207
|
+
# question the ghost asks about the line it hints.
|
|
208
|
+
_tai_line_here "$c" 0 || continue
|
|
209
|
+
_tai_reply "$c"
|
|
205
210
|
done
|
|
206
211
|
fi
|
|
207
212
|
local -i names=0
|
|
@@ -211,7 +216,15 @@ _tai_complete() {
|
|
|
211
216
|
fi
|
|
212
217
|
local stem x
|
|
213
218
|
while IFS= read -r c; do
|
|
214
|
-
[[ -n "$c" && "${c,,}" == "${line,,}"* ]]
|
|
219
|
+
[[ -n "$c" && "${c,,}" == "${line,,}"* ]] || continue
|
|
220
|
+
stem="${c##*[[:space:]]}"
|
|
221
|
+
# The word this completion offers is judged where the shell stands: a
|
|
222
|
+
# learned word that is unambiguously a relative path (the index marked it
|
|
223
|
+
# in _TAI_LIVE) was true where it was recorded and may exist nowhere else.
|
|
224
|
+
# Flags and bare words are never marked; the file and directory sources
|
|
225
|
+
# above read the disk itself.
|
|
226
|
+
_tai_here "$stem" || continue
|
|
227
|
+
_tai_reply "$stem"
|
|
215
228
|
done <<< "$values"
|
|
216
229
|
if (( dir_arg )); then
|
|
217
230
|
local curw=""
|
package/plugins/bash/lookup.bash
CHANGED
|
@@ -161,6 +161,11 @@ _TAI_VALUES_ONE=0
|
|
|
161
161
|
# replaces a walk over 2,600 lines with two or three tests.
|
|
162
162
|
_tai_best() {
|
|
163
163
|
local values="$1" prefix="$2" best="" best_score=-1 c s cl pl="${2,,}"
|
|
164
|
+
# The words tai adds are the words judged: the ghost writes the whole line it
|
|
165
|
+
# hints, so every marked word after what the user typed has to resolve here,
|
|
166
|
+
# or the hint is an error the user is offered.
|
|
167
|
+
_tai_typed_words "$prefix"
|
|
168
|
+
local typed=$_TAI_TYPED_WORDS
|
|
164
169
|
# The extends-the-line and echo tests are case-insensitive — the lookup that
|
|
165
170
|
# filled `values` is, and the two have to agree about what extends what.
|
|
166
171
|
if [[ "${_TAI_VALUES_ONE:-0}" == "1" ]]; then
|
|
@@ -171,6 +176,7 @@ _tai_best() {
|
|
|
171
176
|
# what it finds, and a byte that slips through is an escape sequence on
|
|
172
177
|
# someone's terminal.
|
|
173
178
|
[[ "$c" =~ [[:cntrl:]] ]] && continue
|
|
179
|
+
_tai_line_here "$c" "$typed" || continue
|
|
174
180
|
best="$c"
|
|
175
181
|
break
|
|
176
182
|
done <<< "$values"
|
|
@@ -184,6 +190,7 @@ _tai_best() {
|
|
|
184
190
|
# ghost text: the byte goes to the terminal as an escape sequence. The
|
|
185
191
|
# store refuses at record time now, but this shell sources what it finds.
|
|
186
192
|
[[ "$c" =~ [[:cntrl:]] ]] && continue
|
|
193
|
+
_tai_line_here "$c" "$typed" || continue
|
|
187
194
|
s="${_TAI_SCORE[$c]:-0}"
|
|
188
195
|
if (( s > best_score )); then best="$c"; best_score="$s"; fi
|
|
189
196
|
done <<< "$values"
|
|
@@ -224,6 +231,53 @@ _tai_destination_live() {
|
|
|
224
231
|
[[ -d "$t" ]]
|
|
225
232
|
}
|
|
226
233
|
|
|
234
|
+
# The words the index marks in _TAI_LIVE are the learned words that are
|
|
235
|
+
# unambiguously *relative* filesystem paths — the one kind of word whose truth
|
|
236
|
+
# is a fact about the directory it is offered in. A flag is true everywhere and
|
|
237
|
+
# `~/.ollama` is true wherever this machine stands; `Data/INI` was true where it
|
|
238
|
+
# was recorded and may exist nowhere else, and offered from another directory it
|
|
239
|
+
# is the error the user reads the moment they take it. The shape test that
|
|
240
|
+
# decided what gets marked lives in tai/paths.py and is deliberately not
|
|
241
|
+
# re-implemented here — the shell only asks the one question left to it: does
|
|
242
|
+
# the word resolve where the user stands. One builtin stat, paid only on a word
|
|
243
|
+
# the index marked, on the paths that offer it.
|
|
244
|
+
_tai_here() {
|
|
245
|
+
[[ -n "${_TAI_LIVE[$1]:-}" ]] || return 0
|
|
246
|
+
[[ -e "$1" ]]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# The marked words *after* the $2 words already typed all resolve here. This is
|
|
250
|
+
# the question about a whole line — the ghost writes the rest of the line it
|
|
251
|
+
# hints and a first-word completion commits it — so the claim under test is not
|
|
252
|
+
# the next word but every word tai would add. The typed words are the user's
|
|
253
|
+
# own and get no opinion; a line that splits to fewer tokens than that adds
|
|
254
|
+
# nothing and passes. An empty _TAI_LIVE — an index that predates the map, or a
|
|
255
|
+
# history with no relative path in it — passes without a split at all.
|
|
256
|
+
_tai_line_here() {
|
|
257
|
+
local line="$1" n="$2" w
|
|
258
|
+
((${#_TAI_LIVE[@]})) || return 0
|
|
259
|
+
local -a t=()
|
|
260
|
+
read -ra t <<< "$line"
|
|
261
|
+
for w in "${t[@]:n}"; do
|
|
262
|
+
_tai_here "$w" || return 1
|
|
263
|
+
done
|
|
264
|
+
return 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# How many words of `$1` are complete: a trailing space finishes the word before
|
|
268
|
+
# it, so `ls ` has one and `ls Da` still has only the command. Answered in
|
|
269
|
+
# _TAI_TYPED_WORDS and not in the return status, because a pasted line can carry
|
|
270
|
+
# more words than a status can hold and a wrapped count is a wrong count.
|
|
271
|
+
_TAI_TYPED_WORDS=0
|
|
272
|
+
_tai_typed_words() {
|
|
273
|
+
local -a t=()
|
|
274
|
+
read -ra t <<< "$1"
|
|
275
|
+
local n=${#t[@]}
|
|
276
|
+
[[ "$1" == *' ' ]] || (( n -= 1 ))
|
|
277
|
+
(( n < 0 )) && n=0
|
|
278
|
+
_TAI_TYPED_WORDS=$n
|
|
279
|
+
}
|
|
280
|
+
|
|
227
281
|
# Rank the line behind the wrapper and put the wrapper back. `sudo git ` completes
|
|
228
282
|
# from the `git ...` the user has run.
|
|
229
283
|
_tai_wrapped() {
|
package/plugins/zsh/index.zsh
CHANGED
|
@@ -15,7 +15,7 @@ _TAI_INDEX_STAMP="${_TAI_INDEX_FILE}.stamp"
|
|
|
15
15
|
# Declare the maps even when the index file is missing, so a shell started before
|
|
16
16
|
# the first `tai refresh` is quiet and still gets the installed-command
|
|
17
17
|
# fallback.
|
|
18
|
-
typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE
|
|
18
|
+
typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE
|
|
19
19
|
|
|
20
20
|
# Is this file the index, or something that only looks like it?
|
|
21
21
|
#
|
|
@@ -63,7 +63,7 @@ _tai_load_index() {
|
|
|
63
63
|
# suggestion from the store, the next prompt re-read the index, and the stale
|
|
64
64
|
# key was still there. Clearing first is what makes the file the whole truth
|
|
65
65
|
# rather than a growing superset of every index this shell has ever seen.
|
|
66
|
-
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=()
|
|
66
|
+
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=() _TAI_LIVE=()
|
|
67
67
|
# A lookup may cache an answer about the lines it was asked from, so a reload
|
|
68
68
|
# has to retire those answers: bumping the generation is what makes a cached
|
|
69
69
|
# answer speak about the index that is installed now rather than the one that
|
package/plugins/zsh/lookup.zsh
CHANGED
|
@@ -64,6 +64,61 @@ _TAI_CTRL=$'[\x01-\x1f\x7f]'
|
|
|
64
64
|
# shared by hundreds of commands, where the answer is a guess either way.
|
|
65
65
|
typeset -gi _TAI_PREFIX_KEYS=64
|
|
66
66
|
|
|
67
|
+
# The words the index marks in _TAI_LIVE are the learned words that are
|
|
68
|
+
# unambiguously *relative* filesystem paths — the one kind of word whose truth
|
|
69
|
+
# is a fact about the directory it is offered in. A flag is true everywhere and
|
|
70
|
+
# `~/.ollama` is true wherever this machine stands; `Data/INI` was true where it
|
|
71
|
+
# was recorded and may exist nowhere else, and offered from another directory it
|
|
72
|
+
# is the error the user reads the moment they take it. The shape test that
|
|
73
|
+
# decided what gets marked lives in tai/paths.py and is deliberately not
|
|
74
|
+
# re-implemented here — the shell only asks the one question left to it: does
|
|
75
|
+
# the word resolve where the user stands. One builtin stat, paid only on a word
|
|
76
|
+
# the index marked, on the paths that offer it.
|
|
77
|
+
_tai_here() {
|
|
78
|
+
(( ${+_TAI_LIVE[$1]} )) || return 0
|
|
79
|
+
[[ -e "$1" ]]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# The marked words *after* the $2 words already typed all resolve here. This is
|
|
83
|
+
# the question about a whole line — the ghost writes the rest of the line it
|
|
84
|
+
# hints, a glimpse pick replaces the line, a first-word menu commits it — so the
|
|
85
|
+
# claim under test is not the next word but every word tai would add. The typed
|
|
86
|
+
# words are the user's own and get no opinion; a line that splits to fewer
|
|
87
|
+
# tokens than that adds nothing and passes. An empty _TAI_LIVE — an index that
|
|
88
|
+
# predates the map, or a history with no relative path in it — passes without a
|
|
89
|
+
# split at all.
|
|
90
|
+
_tai_line_here() {
|
|
91
|
+
local line="$1" w
|
|
92
|
+
integer n="$2"
|
|
93
|
+
(( ${#_TAI_LIVE} )) || return 0
|
|
94
|
+
local -a t
|
|
95
|
+
t=( ${=line} )
|
|
96
|
+
# zsh's own slice, not the bash-style `${t[@]:n}`: the colon there is a
|
|
97
|
+
# subscript flag the parser reads as a modifier and errors on.
|
|
98
|
+
for w in "${(@)t[n + 1, -1]}"; do
|
|
99
|
+
_tai_here "$w" || return 1
|
|
100
|
+
done
|
|
101
|
+
return 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# How many words of `$1` are complete: a trailing space finishes the word before
|
|
105
|
+
# it, so `ls ` has one and `ls Da` still has only the command. The count the
|
|
106
|
+
# line question needs, so the words tai adds are exactly the words judged.
|
|
107
|
+
# Answered in _TAI_TYPED_WORDS and not in the return status, because a pasted
|
|
108
|
+
# line can carry more words than a status can hold and a wrapped count is a
|
|
109
|
+
# wrong count. The name is not the widgets' `_TAI_TYPED` — that one is the
|
|
110
|
+
# typing flag the arrow-down gate reads, and a lookup clobbering it on every
|
|
111
|
+
# keystroke was breaking the glimpse it opens.
|
|
112
|
+
_tai_typed_words() {
|
|
113
|
+
local -a t
|
|
114
|
+
t=( ${=1} )
|
|
115
|
+
integer n=${#t}
|
|
116
|
+
[[ "$1" == *' ' ]] || (( n -= 1 ))
|
|
117
|
+
(( n < 0 )) && n=0
|
|
118
|
+
_TAI_TYPED_WORDS=$n
|
|
119
|
+
}
|
|
120
|
+
typeset -gi _TAI_TYPED_WORDS=0
|
|
121
|
+
|
|
67
122
|
# The candidate lines for a command name — the word before the first space — into
|
|
68
123
|
# _TAI_LINES, newline-joined the way the index stores them.
|
|
69
124
|
#
|
|
@@ -288,6 +343,19 @@ _tai_lines() {
|
|
|
288
343
|
# _TAI_PREFIX_KEYS of them.
|
|
289
344
|
_tai_best() {
|
|
290
345
|
local prefix="$1" c line best="" best_score=-1 s
|
|
346
|
+
integer typed=0 leadw=0
|
|
347
|
+
# The words tai adds are the words judged: a candidate is hinted whole, so
|
|
348
|
+
# every marked word after what the user typed has to resolve here, or the
|
|
349
|
+
# hint is an error the user is offered. The lead carries the wrapper's words
|
|
350
|
+
# — typed by the user, absent from the stored line — so they come off the
|
|
351
|
+
# count before the test.
|
|
352
|
+
_tai_typed_words "$prefix"
|
|
353
|
+
typed=$_TAI_TYPED_WORDS
|
|
354
|
+
local -a lt
|
|
355
|
+
lt=( ${=_TAI_LINES_LEAD} )
|
|
356
|
+
leadw=${#lt}
|
|
357
|
+
(( typed -= leadw ))
|
|
358
|
+
(( typed < 0 )) && typed=0
|
|
291
359
|
# The extends-the-line and echo tests are case-insensitive — the lookup that
|
|
292
360
|
# filled _TAI_LINES is, and the two have to agree about what extends what.
|
|
293
361
|
# `(#i)` needs extendedglob; localoptions hands this shell its own options
|
|
@@ -298,6 +366,7 @@ _tai_best() {
|
|
|
298
366
|
for c in "${_TAI_LINES[@]}"; do
|
|
299
367
|
line="$_TAI_LINES_LEAD$c"
|
|
300
368
|
[[ -z "$c" || "$line" == (#i)"$prefix" ]] && continue
|
|
369
|
+
_tai_line_here "$c" "$typed" || continue
|
|
301
370
|
best="$line"
|
|
302
371
|
break
|
|
303
372
|
done
|
|
@@ -305,6 +374,7 @@ _tai_best() {
|
|
|
305
374
|
for c in "${_TAI_LINES[@]}"; do
|
|
306
375
|
line="$_TAI_LINES_LEAD$c"
|
|
307
376
|
[[ -z "$c" || "$line" == (#i)"$prefix" || "$line" != (#i)"$prefix"* ]] && continue
|
|
377
|
+
_tai_line_here "$c" "$typed" || continue
|
|
308
378
|
s="${_TAI_SCORE[$c]:-0}"
|
|
309
379
|
if (( s > best_score )); then best="$c"; best_score="$s"; fi
|
|
310
380
|
done
|
|
@@ -457,6 +527,20 @@ _tai_query_do() {
|
|
|
457
527
|
done
|
|
458
528
|
_TAI_LOOSE_LINES=( "${live_l[@]}" )
|
|
459
529
|
fi
|
|
530
|
+
# The same judgement for every other line the glimpse holds, because a pick
|
|
531
|
+
# here replaces the whole typed line: a relative path in a glimpse line was
|
|
532
|
+
# true where the line was recorded, and from this directory it can be the
|
|
533
|
+
# error the user is handed instead of a command. The cd lines above get the
|
|
534
|
+
# stricter destination test; these get the words test — the same rule, at
|
|
535
|
+
# the depth each line claims. The list is filtered fresh per query rather
|
|
536
|
+
# than inside the glimpse, whose cache outlives the directory.
|
|
537
|
+
local -a here_l
|
|
538
|
+
local gline
|
|
539
|
+
here_l=()
|
|
540
|
+
for gline in "${_TAI_LOOSE_LINES[@]}"; do
|
|
541
|
+
_tai_line_here "$gline" 0 && here_l+=( "$gline" )
|
|
542
|
+
done
|
|
543
|
+
_TAI_LOOSE_LINES=( "${here_l[@]}" )
|
|
460
544
|
fi
|
|
461
545
|
|
|
462
546
|
# A learned cd destination was judged for liveness where it was recorded,
|
package/plugins/zsh/menu.zsh
CHANGED
|
@@ -271,6 +271,10 @@ _tai_menu_first_lines() {
|
|
|
271
271
|
sorted=( "${(O)merged[@]}" )
|
|
272
272
|
local m
|
|
273
273
|
for m in "${(@)sorted[1,_TAI_MENU_FIRST_CAP]}"; do
|
|
274
|
+
# A whole line is committed whole, so every marked word in it is judged —
|
|
275
|
+
# the same question the ghost asks about the line it hints, answered the
|
|
276
|
+
# same way, so Tab and the grey text cannot disagree about what works here.
|
|
277
|
+
_tai_line_here "${m[10,-1]}" 0 || continue
|
|
274
278
|
_TAI_MENU_LEARNED+=( "${m[10,-1]}" )
|
|
275
279
|
done
|
|
276
280
|
(( ${#_TAI_MENU_LEARNED[@]} ))
|
|
@@ -365,7 +369,15 @@ _tai_menu_open() {
|
|
|
365
369
|
for c in "${_TAI_LINES[@]}"; do
|
|
366
370
|
(( ${#c} > skip )) || continue
|
|
367
371
|
rest="$_TAI_LINES_LEAD${c}"
|
|
368
|
-
|
|
372
|
+
c="${${rest[skip + 1, -1]}%% *}"
|
|
373
|
+
# The word the menu completes is judged where the menu stands: a learned
|
|
374
|
+
# word that is unambiguously a relative path (the index marked it in
|
|
375
|
+
# _TAI_LIVE) was true where it was recorded and may exist nowhere else,
|
|
376
|
+
# and Enter on it reads "no such file or directory". Flags and bare words
|
|
377
|
+
# are never marked; the file and glob sources below are the disk itself.
|
|
378
|
+
[[ -n "$c" ]] || continue
|
|
379
|
+
_tai_here "$c" || continue
|
|
380
|
+
out+=( "$c" ); outq+=( 0 )
|
|
369
381
|
done
|
|
370
382
|
|
|
371
383
|
# 1b. The first word has no `before` for the lookup above, and without this
|
package/plugins/zsh/widgets.zsh
CHANGED
|
@@ -562,6 +562,23 @@ _tai_line_init() {
|
|
|
562
562
|
_TAI_ISEARCH=0
|
|
563
563
|
region_highlight=()
|
|
564
564
|
_tai_menu_close
|
|
565
|
+
# The prophecy. _TAI_SEQ answers "what did I run after this", and the fresh
|
|
566
|
+
# prompt is where that answer is taken — so the reset above is followed by
|
|
567
|
+
# the question, through the same _tai_update the redraw hook runs: it looks
|
|
568
|
+
# the empty buffer up against _TAI_LAST, judges the head's relative paths
|
|
569
|
+
# where the prompt stands, and paints the gray line the arrow accepts. The
|
|
570
|
+
# report: the sequence table held `sudo …` after the denied `systemctl …`
|
|
571
|
+
# and the prompt said nothing, because line-init only ever cleared, and no
|
|
572
|
+
# widget-triggered redraw fires before the first keystroke — by which time
|
|
573
|
+
# the buffer is no longer empty and the ordinary prefix lookup has replaced
|
|
574
|
+
# the question. Nothing here can answer before the first command has run:
|
|
575
|
+
# _TAI_LAST is set by precmd, so a shell that just opened queries an empty
|
|
576
|
+
# last command and paints nothing — the prophecy is learned, never a
|
|
577
|
+
# default. The query is cached, so the first real redraw of this line
|
|
578
|
+
# restores the answer instead of recomputing it, and every guard the redraw
|
|
579
|
+
# path applies (isearch stand-down, ours-only clearing, the paste rule) is
|
|
580
|
+
# this path's guard too, because it is the same call.
|
|
581
|
+
_tai_update
|
|
565
582
|
}
|
|
566
583
|
|
|
567
584
|
# The isearch flag the redraw hook stands down under, and the pair that
|
package/readme.md
CHANGED
|
@@ -14,6 +14,9 @@ autocomplete that knows *you*:
|
|
|
14
14
|
systemctl <→> restart nginx ← the hint, taken
|
|
15
15
|
git <Tab> pull status ← your habits, ranked
|
|
16
16
|
cd D<Tab> Desktop/ Documents/ Downloads/
|
|
17
|
+
|
|
18
|
+
systemctl stop cups.path
|
|
19
|
+
❯ sudo systemctl stop cups.path ← what you did last time, already waiting
|
|
17
20
|
```
|
|
18
21
|
|
|
19
22
|
![tai in action — the hint, the ranked list, the filesystem, a typo
|
|
@@ -77,7 +80,11 @@ tai uninstall # remove everything tai added
|
|
|
77
80
|
`Ctrl-F` takes it whole, `Alt-F` one word.
|
|
78
81
|
2. **Press `Tab`.** On a first word your history ranks the lines it knows;
|
|
79
82
|
`Enter` fills one in, a second `Enter` runs it.
|
|
80
|
-
3. **Run
|
|
83
|
+
3. **Run a command and look at the next prompt.** When a command fails the way
|
|
84
|
+
it failed before, the empty line already shows, in grey, what you did about
|
|
85
|
+
it last time — `sudo …` after the denied `systemctl …`, `rm -r …` after the
|
|
86
|
+
refused `rm`. `→` takes it; typing replaces it.
|
|
87
|
+
4. **Run `tai web`.** A localhost dashboard of everything it learned, with a
|
|
81
88
|
try box that answers exactly what the prompt would.
|
|
82
89
|
|
|
83
90
|
Every command you run teaches it — secrets, multi-line pastes and stray keys
|
|
@@ -88,7 +95,7 @@ machine. New commands show up within seconds; no restart, ever.
|
|
|
88
95
|
|
|
89
96
|
| key | what it does |
|
|
90
97
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
91
|
-
| `→` | take the hint at the end of the line, else move right
|
|
98
|
+
| `→` | take the hint at the end of the line, else move right — on a fresh prompt the hint is what followed the command you just ran |
|
|
92
99
|
| `Ctrl-F` | take the hint |
|
|
93
100
|
| `Alt-F` / `Ctrl-Right` | take **one word** of the hint, leaving the rest to type (bash: `Ctrl-Right`) |
|
|
94
101
|
| `Tab` | write what every candidate shares, then cycle files, folders and options; on a bare first word the learned lines come first, ranked like the dashboard |
|
|
@@ -137,6 +144,11 @@ memory, an array hit in microseconds.
|
|
|
137
144
|
exists, here, on your PATH.
|
|
138
145
|
- **A tool you have never run still gets an answer:** `tool --help`. Run it
|
|
139
146
|
once and the real `--help` is learned in the background.
|
|
147
|
+
- **A fresh prompt carries what came next.** After a command, the empty line
|
|
148
|
+
shows in grey what your own history followed it with — ranked by how often
|
|
149
|
+
it followed, judged where the prompt stands like every suggestion. It is
|
|
150
|
+
learned or it is nothing: nothing built in, nothing before your first
|
|
151
|
+
command, and a follower whose path does not resolve here is not offered.
|
|
140
152
|
|
|
141
153
|
- **If another plugin also draws hints, both work.** `→`, `Ctrl-F` and
|
|
142
154
|
`Alt-F` take whatever is on screen, and tai never overwrites a hint it did
|
package/tai/index.py
CHANGED
|
@@ -11,6 +11,8 @@ Each snapshot stores:
|
|
|
11
11
|
_TAI_WORD[word-prefix] newline-separated candidates
|
|
12
12
|
_TAI_SEQ[previous] newline-separated candidates
|
|
13
13
|
_TAI_FILE[line-without-its-last-word] 1, when that word is a file
|
|
14
|
+
_TAI_LIVE[word] 1, when the word is a relative path the shell
|
|
15
|
+
must find where it stands before offering it
|
|
14
16
|
|
|
15
17
|
Commands are escaped as POSIX single-quoted strings, which zsh accepts.
|
|
16
18
|
"""
|
|
@@ -25,7 +27,7 @@ from pathlib import Path
|
|
|
25
27
|
from tai.engine import (Engine, LEN_PENALTY_DIV, MAX_LEN_PEN, W_FREQ,
|
|
26
28
|
W_RECENCY, W_SUCCESS, W_INSTALLED)
|
|
27
29
|
from tai.knowledge import load_all as load_tool_knowledge
|
|
28
|
-
from tai.paths import destinations_first, takes_file
|
|
30
|
+
from tai.paths import destinations_first, relative_word_set, takes_file
|
|
29
31
|
from tai.seed import SEED_COMMANDS
|
|
30
32
|
from tai.store import load_rows
|
|
31
33
|
from tai.typo import shadow_map
|
|
@@ -411,15 +413,19 @@ def build(max_commands: int = 20000) -> int:
|
|
|
411
413
|
|
|
412
414
|
seq: dict[str, list[str]] = defaultdict(list)
|
|
413
415
|
for prev, counter in eng.seq.items():
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
#
|
|
417
|
-
#
|
|
418
|
-
#
|
|
419
|
-
#
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
416
|
+
# Ordered by how often the pair happened, then by how good the line is,
|
|
417
|
+
# best first — because the head of this list is the line a fresh prompt
|
|
418
|
+
# offers, and "what did I run after this" is answered by what followed
|
|
419
|
+
# *most*, not by the best-scoring line that ever did. The engine's own
|
|
420
|
+
# sequence factor is the pair's normalized count for the same reason;
|
|
421
|
+
# ordering the list by base score alone (an earlier shape, with the
|
|
422
|
+
# count discarded here) made the prophecy disagree with the engine on
|
|
423
|
+
# exactly the key the prophecy exists for: after `git add -A`, the
|
|
424
|
+
# history's `git commit -m …` lost the head to a bare `ls` that had
|
|
425
|
+
# merely followed once and scored well on its own.
|
|
426
|
+
values = [cmd for cmd, _, _ in sorted(
|
|
427
|
+
((cmd, counter[cmd], scores.get(cmd, 0)) for cmd in counter),
|
|
428
|
+
key=lambda x: (-x[1], -x[2], x[0]))[:SEQ_CANDIDATE_CAP]]
|
|
423
429
|
# A key whose every follower was stale has no answer left, and writing
|
|
424
430
|
# it as an empty string puts `_TAI_SEQ[key] = ''` in the file. The zsh
|
|
425
431
|
# plugin splits that into one empty line and the bash one reads it as an
|
|
@@ -445,13 +451,21 @@ def build(max_commands: int = 20000) -> int:
|
|
|
445
451
|
if head:
|
|
446
452
|
file_keys.add(head)
|
|
447
453
|
|
|
454
|
+
# The words the shells must ask the filesystem about before offering them:
|
|
455
|
+
# unambiguously relative paths, by the shape test paths.py already owns.
|
|
456
|
+
# Everything else is either not a path (flags, subcommands, bare words a
|
|
457
|
+
# `git` may mean), cwd-independent (`~`-paths and absolutes, judged at build
|
|
458
|
+
# by the stale set), or unjudgeable — and the module's rule is that an
|
|
459
|
+
# ambiguous token gets no opinion.
|
|
460
|
+
live = relative_word_set(cmds)
|
|
461
|
+
|
|
448
462
|
zsh_file = index_path()
|
|
449
463
|
zsh_file.parent.mkdir(parents=True, exist_ok=True)
|
|
450
464
|
bash_file = bash_index_path()
|
|
451
465
|
tmp = zsh_file.with_suffix(".tmp")
|
|
452
466
|
with tmp.open("w", encoding="utf-8") as f:
|
|
453
467
|
f.write("# tai embedded zsh index; generated, do not edit\n")
|
|
454
|
-
f.write("typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE\n")
|
|
468
|
+
f.write("typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE\n")
|
|
455
469
|
# `_TAI_X+=(key value)` — a pair appended to an associative array, one
|
|
456
470
|
# builtin call per entry and no function call at all.
|
|
457
471
|
#
|
|
@@ -473,12 +487,14 @@ def build(max_commands: int = 20000) -> int:
|
|
|
473
487
|
f.write(f"_TAI_SEQ+=({_zq(key)} {_zq(chr(10).join(values))})\n")
|
|
474
488
|
for key in sorted(file_keys):
|
|
475
489
|
f.write(f"_TAI_FILE+=({_zq(key)} 1)\n")
|
|
490
|
+
for word in sorted(live):
|
|
491
|
+
f.write(f"_TAI_LIVE+=({_zq(word)} 1)\n")
|
|
476
492
|
os.replace(tmp, zsh_file)
|
|
477
493
|
|
|
478
494
|
bash_tmp = bash_file.with_suffix(".tmp")
|
|
479
495
|
with bash_tmp.open("w", encoding="utf-8") as f:
|
|
480
496
|
f.write("# tai embedded bash index; generated, do not edit\n")
|
|
481
|
-
f.write("declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE\n")
|
|
497
|
+
f.write("declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE\n")
|
|
482
498
|
for score, cmd in ranked:
|
|
483
499
|
f.write(f"_TAI_SCORE[{_zq(cmd)}]={score}\n")
|
|
484
500
|
for key, values in sorted(first.items()):
|
|
@@ -489,6 +505,8 @@ def build(max_commands: int = 20000) -> int:
|
|
|
489
505
|
f.write(f"_TAI_SEQ[{_zq(key)}]={_zq(chr(10).join(values))}\n")
|
|
490
506
|
for key in sorted(file_keys):
|
|
491
507
|
f.write(f"_TAI_FILE[{_zq(key)}]=1\n")
|
|
508
|
+
for word in sorted(live):
|
|
509
|
+
f.write(f"_TAI_LIVE[{_zq(word)}]=1\n")
|
|
492
510
|
os.replace(bash_tmp, bash_file)
|
|
493
511
|
_compile_zsh_index()
|
|
494
512
|
return len(cmds)
|
package/tai/paths.py
CHANGED
|
@@ -459,3 +459,58 @@ def stale_in(engine, cwd: str = "") -> set[str]:
|
|
|
459
459
|
return stale_commands(
|
|
460
460
|
((cmd, list(st.cwd)) for cmd, st in engine.cmds.items()),
|
|
461
461
|
cwd)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def relative_word_set(cmds) -> frozenset[str]:
|
|
465
|
+
"""The learned words that are unambiguously *relative* filesystem paths.
|
|
466
|
+
|
|
467
|
+
The one kind of word whose truth is a fact about the directory it is
|
|
468
|
+
offered in. A flag is true everywhere; `~/.ollama` and `/usr/bin` are true
|
|
469
|
+
wherever this machine stands, and were already judged at build; `Data/INI`
|
|
470
|
+
was true where it was recorded and may exist nowhere else — offered from
|
|
471
|
+
another directory it is the error the user is shown the moment they take
|
|
472
|
+
it. The shape test is this module's, unquoted first: reimplementing it in
|
|
473
|
+
two shells is how `git diff ~main` and `date 1/2/2024` come to be judged
|
|
474
|
+
as paths. The index writes the set out as _TAI_LIVE, and the shells ask
|
|
475
|
+
the filesystem the one question a marked word costs.
|
|
476
|
+
"""
|
|
477
|
+
out: set[str] = set()
|
|
478
|
+
for cmd in cmds:
|
|
479
|
+
for token in (cmd or "").split():
|
|
480
|
+
token = _unquote(token)
|
|
481
|
+
if token.startswith("~") or token.startswith("/"):
|
|
482
|
+
continue
|
|
483
|
+
if is_path_like(token):
|
|
484
|
+
out.add(token)
|
|
485
|
+
return frozenset(out)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def alive_in(cmd: str, cwd: str = "") -> bool:
|
|
489
|
+
"""True when every relative path in `cmd` resolves in `cwd`.
|
|
490
|
+
|
|
491
|
+
`command_verdict` above asks whether a command was ever true: a row
|
|
492
|
+
recorded in a directory that still holds the path survives. This asks the
|
|
493
|
+
other question, the one the user answers with Enter: is it true *here*,
|
|
494
|
+
in the directory the suggestion is being shown in? A `ls Data/INI`
|
|
495
|
+
recorded in the directory that has Data/INI is alive by the first answer
|
|
496
|
+
and dead by the second everywhere else, and it was the second answer the
|
|
497
|
+
report was about.
|
|
498
|
+
|
|
499
|
+
Every judgement is a stat against `cwd`, never a removal decision about a
|
|
500
|
+
stored row — this gates what is shown, and the recorded evidence above
|
|
501
|
+
stays the only thing that can retire a row. No directory, no opinion.
|
|
502
|
+
"""
|
|
503
|
+
if not enabled():
|
|
504
|
+
return True
|
|
505
|
+
cwd = cwd or os.getcwd()
|
|
506
|
+
if not cmd or not os.path.isdir(cwd):
|
|
507
|
+
return True
|
|
508
|
+
for token in cmd.split():
|
|
509
|
+
token = _unquote(token)
|
|
510
|
+
if not is_path_like(token):
|
|
511
|
+
continue
|
|
512
|
+
if token.startswith("~") or os.path.isabs(token):
|
|
513
|
+
continue
|
|
514
|
+
if not os.path.exists(os.path.join(cwd, token)):
|
|
515
|
+
return False
|
|
516
|
+
return True
|
package/tai/predictor.py
CHANGED
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
|
|
15
15
|
from tai.engine import WRAPPERS, Engine
|
|
16
16
|
from tai.fresh import files
|
|
17
|
-
from tai.paths import db_path, takes_file
|
|
17
|
+
from tai.paths import alive_in, db_path, takes_file
|
|
18
18
|
|
|
19
19
|
_E: Engine | None = None
|
|
20
20
|
_STALE: frozenset = frozenset()
|
|
@@ -95,6 +95,25 @@ def suggest(prefix: str = "", cwd: str = "", repo: str = "",
|
|
|
95
95
|
res = _E.suggest(prefix=prefix or "", cwd=cwd, repo=repo, branch=branch,
|
|
96
96
|
last_commands=last_commands or [], limit=limit, temp=temp,
|
|
97
97
|
exclude=_STALE, on_path=_on_path)
|
|
98
|
+
# The stale set above never hides a relative path that was true where it
|
|
99
|
+
# was recorded — which is most of them, and that is right for a snapshot
|
|
100
|
+
# shown in every directory. This answer is shown in one directory, so its
|
|
101
|
+
# lines are judged there too: a line whose relative path does not resolve
|
|
102
|
+
# where the answer will be taken is not an answer, and Enter on it is the
|
|
103
|
+
# error the user reads. The same rule the index writes into _TAI_LIVE for
|
|
104
|
+
# the shells, from the same module, so the rooms cannot disagree about
|
|
105
|
+
# what works where the user stands.
|
|
106
|
+
_where = cwd or os.getcwd()
|
|
107
|
+
_all = res.get("choices", [])
|
|
108
|
+
_live = [c for c in _all if alive_in(c["cmd"], _where)]
|
|
109
|
+
if len(_live) != len(_all):
|
|
110
|
+
res["choices"] = _live
|
|
111
|
+
res["probabilities"] = {c["cmd"]: c["prob"] for c in _live}
|
|
112
|
+
_best = _live[0]["cmd"] if _live else ""
|
|
113
|
+
res["choice"] = _best
|
|
114
|
+
res["completion"] = (_best[len(prefix or ""):]
|
|
115
|
+
if _best.lower().startswith((prefix or "").lower())
|
|
116
|
+
else "")
|
|
98
117
|
res = _file_answer(prefix or "", res)
|
|
99
118
|
# The engine measures its own ranking; a caller that only cares about the
|
|
100
119
|
# whole answer wants the process time, and a sub-millisecond rank has no
|